Merge pull request #12437 from backstage/search-v1

[Search] Release v1
This commit is contained in:
Raghunandan Balachandran
2022-07-18 11:17:04 +02:00
committed by GitHub
41 changed files with 81 additions and 2621 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-search': major
'@backstage/plugin-search-react': major
'@backstage/plugin-search-backend': major
'@backstage/plugin-search-backend-node': major
'@backstage/plugin-search-common': major
'@backstage/plugin-search-backend-module-elasticsearch': major
---
This package has been promoted to 1.0. Read more about what it means in [New release: Backstage Search 1.0 blog](https://backstage.io/blog/2022/07/19/releasing-backstage-search-1.0)
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-pg': patch
---
Now imports `SearchEngine` interface from `@backstage/plugin-search-common` instead of `@backstage/plugin-search-backend-node`
+36 -66
View File
@@ -14,60 +14,16 @@ Backstage Search lets you find the right information you are looking for in the
## Features
- A federated, faceted search, searching across all entities registered in your Backstage instance.
- A search that lets you bring your own search engine.
- A search that lets you extend it by creating collators for easily indexing content from plugins and other sources.
- A search that lets you create composable search page experiences.
- A search that lets you customize the look and feel of each search result.
- A search that lets you plug in your own search engine of choice.
- A standardized search API where you can choose to index data from other plugins.
## Project roadmap
### Now
**Backstage Search 1.0**
A stable Search API for plugin developers to add search to their plugins, and app integrators to expose that to their users.
We will consider Backstage Search to be 1.0 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.
### Next
_Not specified_
### Someday/Maybe
_Not specified_
### Done
See [Done](#done) below for a list of completed roadmap items.
See the more detailed [architecture](./architecture.md) and [tech stack](./architecture.md#tech-stack).
## Supported
The following sections show the search engines and plugins currently supported by Backstage Search.
### Search engines
See [Backstage Search Architecture](architecture.md) to get an overview of how
the search engines are used.
| Search Engines | Support Status |
| -------------------------------------------------- | -------------- |
| [Lunr](./search-engines.md#lunr) | ✅ |
| [ElasticSearch](./search-engines.md#elasticsearch) | ✅ |
| [Postgres](./search-engines.md#postgres) | ✅ |
[Reach out to us](#get-involved) if you want to chat about support for more
search engines.
The following sections show the plugins and search engines currently supported by Backstage Search.
### Plugins integrated with Backstage Search
@@ -77,20 +33,19 @@ search engines.
| [TechDocs](./how-to-guides.md#how-to-index-techdocs-documents) | ✅ |
| [Stack Overflow](https://github.com/backstage/backstage/blob/master/plugins/stack-overflow-backend/README.md#index-stack-overflow-questions-to-search) | ✅ |
[Reach out to us](#get-involved) if you want to chat about support for more
plugins integrated to search.
### Search engines
## Tech Stack
See [Backstage Search Architecture](architecture.md) to get an overview of how
the search engines are used.
| Stack | Location |
| ------------------------- | ----------------------------------------------------- |
| Frontend Plugin | @backstage/plugin-search |
| Frontend Plugin Library | @backstage/plugin-search-react |
| Isomorphic Plugin Library | @backstage/plugin-search-common |
| Backend Plugin | @backstage/plugin-search-backend |
| Backend Plugin Library | @backstage/plugin-search-backend-node |
| Backend Plugin Module | @backstage/plugin-search-backend-module-elasticsearch |
| Backend Plugin Module | @backstage/plugin-search-backend-module-pg |
| Search Engines | Support Status |
| -------------------------------------------------- | -------------- |
| [ElasticSearch](./search-engines.md#elasticsearch) | ✅ |
| [Lunr](./search-engines.md#lunr) | ✅ |
| [Postgres](./search-engines.md#postgres) | Community ✅ |
[Reach out to us](#get-involved) if you want to chat about support for more plugin integrations and
search engines.
## Get involved
@@ -98,9 +53,9 @@ For any questions, feedback, or to help move search forward, reach out to us in
the **#search** channel of our
[Discord chatroom](https://github.com/backstage/backstage#community).
## Done
## Use Cases supported
**Backstage Search Pre-Alpha**
#### **Backstage Search Pre-Alpha**
Search Frontend letting you search through the entities of the software catalog.
@@ -118,7 +73,7 @@ there by means of a front-end only, non-extensible MVP.
- As a software engineer I should be able to hide the filters if I dont need to
use them.
**Backstage Search Alpha**
#### **Backstage Search Alpha**
Basic “out-of-the-box” in-memory indexing process of entities, and their metadata, registered to the Software Catalog.
@@ -136,7 +91,7 @@ met, but built on top of a flexible, extensible platform.
should be possible to have the pre-alpha user experiences covered without
having to set up and configure a search engine.
**Backstage Search Beta**
#### **Backstage Search Beta**
At least one production-ready search engine that supports the same use-cases as in the alpha.
@@ -151,3 +106,18 @@ are met, and can be deployed using a production-ready search engine.
- 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.
#### **Backstage Search 1.0**
A stable Search API for plugin developers to add search to their plugins, and app integrators to expose that to their users.
We will consider Backstage Search to be 1.0 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.
+12 -4
View File
@@ -4,10 +4,6 @@ title: Search Architecture
description: Documentation on Search Architecture
---
> _This architecture has not been fully implemented yet. Find our milestones to
> follow our progress and help contribute on the
> [Search Roadmap](./README.md#project-roadmap)._
Below you can explore the Search Architecture. Our aim with this architecture is
to support a wide variety of search engines, while providing a simple developer
experience for plugin developers, and a good out-of-the-box experience for
@@ -44,3 +40,15 @@ Architecture non-goals:
- At this time, we do not intend to directly support event-driven or incremental
index management. Instead, we'll be focused on scheduled, bulk index
management.
## Tech Stack
| Stack | Location |
| ------------------------- | ----------------------------------------------------- |
| Frontend Plugin | @backstage/plugin-search |
| Frontend Plugin Library | @backstage/plugin-search-react |
| Isomorphic Plugin Library | @backstage/plugin-search-common |
| Backend Plugin | @backstage/plugin-search-backend |
| Backend Plugin Library | @backstage/plugin-search-backend-node |
| Backend Plugin Module | @backstage/plugin-search-backend-module-elasticsearch |
| Backend Plugin Module | @backstage/plugin-search-backend-module-pg |
-265
View File
@@ -148,271 +148,6 @@ indexBuilder.addCollator({
As shown above, you can add a catalog entity filter to narrow down what catalog
entities are indexed by the search engine.
## How to migrate from Search Alpha to Beta
For the purposes of this guide, Search Beta version is defined as:
- **Search Plugin**: At least `v0.7.2`
- **Search Backend Plugin**: At least `v0.4.6`
- **Search Backend Node**: At least `v0.5.0`
- **Search Common**: At least `v0.3.0`
In the Beta version, the Search Platform's indexing process has been rewritten
as a stream pipeline in order to improve efficiency and performance on large
sets of documents.
If you've not yet extended the Search Platform with custom code, and have
instead taken advantage of default collators, decorators, and search engines
provided by existing plugins, the migration process is fairly straightforward:
1. Upgrade to at least version `0.5.0` of
`@backstage/plugin-search-backend-node`, as well as any backend plugins whose
collators you are using (e.g. at least version `0.23.0` of
`@backstage/plugin-catalog-backend` and/or version `0.14.1` of
`@backstage/plugin-techdocs-backend`), as well as any search-engine specific
plugin you are using (e.g. at least version `0.3.0` of
`@backstage/plugin-search-backend-module-pg` or version `0.1.0` of
`@backstage/plugin-search-backend-module-elasticsearch`).
2. Then, make the following changes to your
`/packages/backend/src/plugins/search.ts` file:
```diff
-import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
-import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend';
+import { DefaultCatalogCollatorFactory } from '@backstage/plugin-catalog-backend';
+import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend';
// ...
const indexBuilder = new IndexBuilder({ logger: env.logger, searchEngine });
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
- collator: DefaultCatalogCollator.fromConfig(env.config, {
discovery: env.discovery,
}),
+ factory: DefaultCatalogCollatorFactory.fromConfig(env.config, {
discovery: env.discovery,
}),
});
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
- collator: DefaultTechDocsCollator.fromConfig(env.config, {
+ factory: DefaultTechDocsCollatorFactory.fromConfig(env.config, {
discovery: env.discovery,
logger: env.logger,
}),
});
```
Any custom collators, decorators, or search engine implementations will require
minor refactoring. Continue on for details.
### Rewriting alpha-style collators for beta
In alpha versions of the Backstage Search Platform, collators were classes that
implemented an `execute` method which resolved an `IndexableDocument` array.
In beta versions, the logic encapsulated by the aforementioned `execute` method
is contained within an [object-mode][obj-mode] `Readable` stream where each
object pushed onto the stream is of type `IndexableDocument`. Instances of this
stream are instantiated by a factory class conforming to the
`DocumentCollatorFactory` interface.
The optimal conversion strategy will vary depending on the collator's logic, but
the simplest conversion can follow a process like this:
1. Rename your collator class to something like `YourCollatorFactory` and update
it to implement `DocumentCollatorFactory` instead of `DocumentCollator`.
2. Update its `execute` method so that it resolves
`AsyncGenerator<YourIndexableDocument>` instead of `YourIndexableDocument[]`.
3. Implement `DocumentCollatorFactory`'s `getCollator` method which resolves to
`Readable.from(this.execute())` (which is a utility for creating [readable
streams][read-stream] from [async generators][async-gen]).
```ts
import { DocumentCollatorFactory } from '@backstage/plugin-search-backend-node';
import { Readable } from 'stream';
export class YourCollatorFactory implements DocumentCollatorFactory {
public readonly type: string = 'your-type';
async *execute(): AsyncGenerator<YourIndexableDocument> {
const widgets = await this.client.getWidgets();
for (const widget of widgets) {
yield {
title: widget.name,
location: widget.url,
text: widget.description,
};
}
}
async getCollator() {
return Readable.from(this.execute());
}
}
```
Note: it may be possible to simplify your collator dramatically! If your custom
collator was previously using streams under the hood (for example, by reading
newline delimited JSON from a local or remote file), you could just expose the
stream directly via a simple factory class:
```ts
import { DocumentCollatorFactory } from '@backstage/plugin-search-backend-node';
import { createReadStream } from 'fs';
import { parse } from '@jsonlines/core';
export class YourCollatorFactory implements DocumentCollatorFactory {
public readonly type: string = 'your-type';
async getCollator() {
const parseStream = parse();
return createReadStream('./documents.ndjson').pipe(parseStream);
}
}
```
### Rewriting alpha-style decorators for beta
In alpha versions of the Backstage Search Platform, decorators were classes that
implemented an `execute` method which took an `IndexableDocument` array as an
argument, and resolved a modified array of the same type.
In beta versions, the logic encapsulated by the aforementioned `execute` method
is contained within an object-mode `Transform` stream which reads objects of
type `IndexableDocument`, and writes objects of a conforming type. Similar to
collators, instances of this stream are instantiated by a factory class
conforming to the `DocumentDecoratorFactory` interface.
Although you can choose to implement a `Transform` stream from scratch, the
`@backstage/plugin-search-backend-node` package provides a `DecoratorBase` class
in order to simplify the developer experience. With this base class, all that's
needed is to transfer your old decorator class logic into the base class' three
methods (`initialize`, `decorate`, and `finalize`), and implement the factory
class that instantiates the stream:
```ts
import { DecoratorBase } from '@backstage/plugin-search-backend-node';
export class YourDecorator extends DecoratorBase {
async initialize() {
// Setup logic. Performed once before any documents are consumed.
}
async decorate(
document: YourIndexableDocument,
): Promise<YourIndexableDocument | YourIndexableDocument[] | undefined> {
// Perform transformation logic here.
return document;
}
async finalize() {
// Teardown logic. Performed once after all documents have been consumed.
}
}
export class YourDecoratorFactory implements DocumentDecoratorFactory {
async getDecorator() {
return new YourDecorator();
}
}
```
Note the return type of the `decorate` method and how each can be used to
different effect.
- By resolving a single `YourIndexableDocument` object, your decorator can be
used to make simple transformations:
```ts
class BooleanWidgetCoolnessDecorator extends DecoratorBase {
async decorator(widget) {
// Perform a simple, 1:1 transformation.
widget.isCool = widget.isCool === 'true' ? true : false;
return widget;
}
}
```
- By resolving `undefined`, your decorator can filter out documents which
shouldn't be in the index:
```ts
class OnlyCoolWidgetsDecorator extends DecoratorBase {
async decorator(widget) {
// Perform a simple filter operation.
return widget.isCool ? widget : undefined;
}
}
```
- By resolving an array of `YourIndexableDocument` objects, you can generate
multiple documents based on the content of one:
```ts
class WidgetByVariantDecorator extends DecoratorBase {
async decorator(widget) {
// Generate one widget doc per widget variant.
return widget.variants.map(variant => {
// Each widget doc is the given widget plus a "variant" property
// pulled from a widget.variants string array.
return {
...widget,
variant,
};
});
}
}
```
In alpha versions, a decorator had access to every `IndexableDocument`
simultaneously. This is no longer possible in beta versions (precisely to make
the indexing process more efficient and performant). You will need to modify
your decorator's logic so that it does not need access to every document at
once.
### Rewriting alpha-style search engines for beta
Search Engines are responsible for both querying and indexing documents to an
underlying search engine technology. While the search engine query interface
didn't change between alpha and beta versions, the indexing half of the
interface _did_ change.
In alpha versions of the Backstage Search Platform, a search engine implemented
an `index` method which took a `type` and an `IndexableDocument` array and was
responsible for writing these documents to the underlying search engine.
In beta versions, the logic encapsulated by the aforementioned `index` method is
contained within an object-mode `Writable` stream which expects objects of type
`IndexableDocument`. On the search engine class itself, the `index` method is
replaced with a `getIndexer` factory method which still takes the `type`, but
resolves an instance of the aforementioned `Writable` stream.
Although you can choose to implement a `Writable` stream from scratch, the
`@backstage/plugin-search-backend-node` package provides a
`BatchSearchEngineIndexer` class in order to simplify the developer experience.
With this base class, which collects documents in batches of a configurable size
on your behalf, all that's needed is to transfer your old `index` method logic
into the base class' three methods (`initialize`, `index`, and `finalize`), and
implement the factory method that instantiates the stream:
```ts
import { BatchSearchEngineIndexer } from '@backstage/plugin-search-backend-node';
import { SearchEngine } from '@backstage/plugin-search-common';
export class YourSearchEngineIndexer extends BatchSearchEngineIndexer {
constructor({ type }: { type: string }) {
// Customize the number of documents passed to the index method per batch.
super({ batchSize: 500 });
// An imaginary search engine indexing client.
this.index = new SomeSearchEngineIndex({ indexName: type });
}
async initialize() {
// Setup logic. Performed once before any documents are consumed.
}
async index(documents: IndexableDocument[]) {
await this.index.batchOf(documents);
}
async finalize() {
// Teardown logic. Performed once after all documents have been consumed.
}
}
export class YourSearchEngine implements SearchEngine {
async getIndexer(type: string) {
return new YourSearchEngineIndexer({ type });
}
}
```
## How to customize search results highlighting styling
The default highlighting styling for matched terms in search results is your
+1
View File
@@ -51,6 +51,7 @@
"@backstage/plugin-scaffolder-backend": "^1.4.0-next.3",
"@backstage/plugin-scaffolder-backend-module-rails": "^0.4.2-next.1",
"@backstage/plugin-search-backend": "^0.5.4-next.2",
"@backstage/plugin-search-common": "^0.3.6-next.0",
"@backstage/plugin-search-backend-node": "^0.6.3-next.2",
"@backstage/plugin-search-backend-module-elasticsearch": "^0.2.0-next.2",
"@backstage/plugin-search-backend-module-pg": "^0.3.5-next.2",
+1 -1
View File
@@ -22,8 +22,8 @@ import { PgSearchEngine } from '@backstage/plugin-search-backend-module-pg';
import {
IndexBuilder,
LunrSearchEngine,
SearchEngine,
} from '@backstage/plugin-search-backend-node';
import { SearchEngine } from '@backstage/plugin-search-common';
import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
@@ -9,7 +9,7 @@ import { IndexableDocument } from '@backstage/plugin-search-common';
import { IndexableResultSet } from '@backstage/plugin-search-common';
import { Knex } from 'knex';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
import { SearchEngine } from '@backstage/plugin-search-common';
import { SearchQuery } from '@backstage/plugin-search-common';
// @public
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { PluginDatabaseManager } from '@backstage/backend-common';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
import { SearchEngine } from '@backstage/plugin-search-common';
import {
SearchQuery,
IndexableResultSet,
@@ -170,8 +170,6 @@ export type ScheduleTaskParameters = {
scheduledRunner: TaskRunner;
};
export { SearchEngine };
// @public
export class TestPipeline {
execute(): Promise<TestPipelineResult>;
@@ -19,10 +19,11 @@ import { TaskInvocationDefinition, TaskRunner } from '@backstage/backend-tasks';
import {
DocumentCollatorFactory,
DocumentDecoratorFactory,
SearchEngine,
} from '@backstage/plugin-search-common';
import { Readable, Transform } from 'stream';
import { IndexBuilder } from './IndexBuilder';
import { LunrSearchEngine, SearchEngine } from './index';
import { LunrSearchEngine } from './index';
class TestDocumentCollatorFactory implements DocumentCollatorFactory {
readonly type: string = 'anything';
-5
View File
@@ -39,8 +39,3 @@ export * from './indexing';
export * from './test-utils';
export type { ScheduleTaskParameters } from './Scheduler';
/**
* @deprecated Import from @backstage/plugin-search-common instead
*/
export type { SearchEngine } from '@backstage/plugin-search-common';
+1 -1
View File
@@ -9,7 +9,7 @@ import express from 'express';
import { Logger } from 'winston';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
import { SearchEngine } from '@backstage/plugin-search-common';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
@@ -17,10 +17,8 @@
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import {
IndexBuilder,
SearchEngine,
} from '@backstage/plugin-search-backend-node';
import { IndexBuilder } from '@backstage/plugin-search-backend-node';
import { SearchEngine } from '@backstage/plugin-search-common';
import express from 'express';
import request from 'supertest';
+1 -1
View File
@@ -33,7 +33,7 @@ import {
IndexableResultSet,
SearchResultSet,
} from '@backstage/plugin-search-common';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
import { SearchEngine } from '@backstage/plugin-search-common';
import { AuthorizedSearchEngine } from './AuthorizedSearchEngine';
const jsonObjectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {
+3 -128
View File
@@ -6,143 +6,24 @@
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { DefaultResultListItemProps } from '@backstage/plugin-search-react';
import { IconComponent } from '@backstage/core-plugin-api';
import { InputBaseProps } from '@material-ui/core';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { SearchAutocompleteFilterProps as SearchAutocompleteFilterProps_2 } from '@backstage/plugin-search-react';
import { SearchBarBaseProps as SearchBarBaseProps_2 } from '@backstage/plugin-search-react';
import { SearchFilterComponentProps as SearchFilterComponentProps_2 } from '@backstage/plugin-search-react';
import { SearchResultProps } from '@backstage/plugin-search-react';
// @public @deprecated (undocumented)
export const DefaultResultListItem: (
props: DefaultResultListItemProps,
) => JSX.Element;
// @public @deprecated (undocumented)
export type FilterOptions = {
kind: Array<string>;
lifecycle: Array<string>;
};
// @public @deprecated (undocumented)
export const Filters: ({
filters,
filterOptions,
resetFilters,
updateSelected,
updateChecked,
}: FiltersProps) => JSX.Element;
// @public @deprecated (undocumented)
export const FiltersButton: ({
numberOfSelectedFilters,
handleToggleFilters,
}: FiltersButtonProps) => JSX.Element;
// @public @deprecated
export type FiltersButtonProps = {
numberOfSelectedFilters: number;
handleToggleFilters: () => void;
};
// @public @deprecated
export type FiltersProps = {
filters: FiltersState;
filterOptions: FilterOptions;
resetFilters: () => void;
updateSelected: (filter: string) => void;
updateChecked: (filter: string) => void;
};
// @public @deprecated (undocumented)
export type FiltersState = {
selected: string;
checked: Array<string>;
};
import { SearchBarBaseProps } from '@backstage/plugin-search-react';
// @public (undocumented)
export const HomePageSearchBar: ({
...props
}: Partial<Omit<SearchBarBaseProps_2, 'onChange' | 'onSubmit'>>) => JSX.Element;
}: Partial<Omit<SearchBarBaseProps, 'onChange' | 'onSubmit'>>) => JSX.Element;
// @public
export type HomePageSearchBarProps = Partial<
Omit<SearchBarBaseProps_2, 'onChange' | 'onSubmit'>
Omit<SearchBarBaseProps, 'onChange' | 'onSubmit'>
>;
// @public (undocumented)
export const Router: () => JSX.Element;
// @public @deprecated (undocumented)
export type SearchAutocompleteFilterProps = SearchFilterComponentProps_2 & {
filterSelectedOptions?: boolean;
limitTags?: number;
multiple?: boolean;
};
// @public @deprecated
export const SearchBar: ({ onChange, ...props }: SearchBarProps) => JSX.Element;
// @public @deprecated
export const SearchBarBase: ({
onChange,
onKeyDown,
onSubmit,
debounceTime,
clearButton,
fullWidth,
value: defaultValue,
inputProps: defaultInputProps,
endAdornment: defaultEndAdornment,
...props
}: SearchBarBaseProps_2) => JSX.Element;
// @public @deprecated
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
debounceTime?: number;
clearButton?: boolean;
onClear?: () => void;
onSubmit?: () => void;
onChange: (value: string) => void;
};
// @public @deprecated
export type SearchBarProps = Partial<SearchBarBaseProps>;
// @public @deprecated (undocumented)
export const SearchFilter: {
({ component: Element, ...props }: SearchFilterWrapperProps): JSX.Element;
Checkbox(
props: Omit<SearchFilterWrapperProps, 'component'> &
SearchFilterComponentProps,
): JSX.Element;
Select(
props: Omit<SearchFilterWrapperProps, 'component'> &
SearchFilterComponentProps,
): JSX.Element;
Autocomplete(props: SearchAutocompleteFilterProps_2): JSX.Element;
};
// @public @deprecated (undocumented)
export type SearchFilterComponentProps = {
className?: string;
name: string;
label?: string;
values?: string[] | ((partial: string) => Promise<string[]>);
defaultValue?: string[] | string | null;
valuesDebounceMs?: number;
};
// @public @deprecated (undocumented)
export type SearchFilterWrapperProps = SearchFilterComponentProps & {
component: (props: SearchFilterComponentProps) => ReactElement;
debug?: boolean;
};
// @public (undocumented)
export const SearchModal: ({
open,
@@ -199,12 +80,6 @@ const searchPlugin: BackstagePlugin<
export { searchPlugin as plugin };
export { searchPlugin };
// @public @deprecated (undocumented)
export const SearchResult: (props: SearchResultProps) => JSX.Element;
// @public @deprecated (undocumented)
export const SearchResultPager: () => JSX.Element;
// @public (undocumented)
export const SearchType: {
(props: SearchTypeProps): JSX.Element;
@@ -1,168 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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%',
},
}));
/**
* @public
* @deprecated This type and corresponding component will be removed in a
* future release.
*/
export type FiltersState = {
selected: string;
checked: Array<string>;
};
/**
* @public
* @deprecated This type and corresponding component will be removed in a
* future release.
*/
export type FilterOptions = {
kind: Array<string>;
lifecycle: Array<string>;
};
/**
* Props for {@link Filters}.
*
* @public
* @deprecated This type and corresponding component will be removed in a
* future release.
*/
export type FiltersProps = {
filters: FiltersState;
filterOptions: FilterOptions;
resetFilters: () => void;
updateSelected: (filter: string) => void;
updateChecked: (filter: string) => void;
};
/**
* @public
* @deprecated This component will be removed in a future release. Use
* `SearchFilter` from `@backstage/plugin-search-react` instead.
*/
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>
);
};
@@ -1,67 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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),
},
}));
/**
* Props for {@link FiltersButton}.
*
* @public
* @deprecated This type and corresponding component will be removed in a
* future release.
*/
export type FiltersButtonProps = {
numberOfSelectedFilters: number;
handleToggleFilters: () => void;
};
/**
* @public
* @deprecated See `SearchFilter` in `@backstage/plugin-search-react` instead.
*/
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>
);
};
@@ -1,20 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { FiltersButton } from './FiltersButton';
export type { FiltersButtonProps } from './FiltersButton';
export { Filters } from './Filters';
export type { FilterOptions, FiltersProps, FiltersState } from './Filters';
@@ -1,146 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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>
);
};
@@ -1,56 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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>
);
};
@@ -1,19 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { FiltersButton } from './FiltersButton';
export { Filters } from './Filters';
export type { FiltersState } from './Filters';
@@ -1,69 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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>
);
};
@@ -1,77 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { Grid } from '@material-ui/core';
import React, { useEffect, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { SearchBar } from './LegacySearchBar';
import { SearchResult } from './LegacySearchResult';
import {
Content,
Header,
Page,
useQueryParamState,
} from '@backstage/core-components';
/**
* @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>
);
};
@@ -1,297 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { 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/lib/useAsync';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Filters, FiltersButton, FiltersState } from './Filters';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
EmptyState,
Link,
Progress,
Table,
TableColumn,
} from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
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?.toLocaleLowerCase('en-US') ||
DEFAULT_NAMESPACE
}/${entity.kind.toLocaleLowerCase('en-US')}/${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>
</>
);
};
@@ -1,17 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { LegacySearchPage } from './LegacySearchPage';
@@ -1,79 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, { useCallback } from 'react';
import { InputBaseProps } from '@material-ui/core';
import {
SearchBarBase as RealSearchBarBase,
useSearch,
} from '@backstage/plugin-search-react';
/**
* Props for {@link SearchBarBase}.
*
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
debounceTime?: number;
clearButton?: boolean;
onClear?: () => void;
onSubmit?: () => void;
onChange: (value: string) => void;
};
/**
* All search boxes exported by the search plugin are based on the <SearchBarBase />,
* and this one is based on the <InputBase /> component from Material UI.
* Recommended if you don't use Search Provider or Search Context.
*
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export const SearchBarBase = RealSearchBarBase;
/**
* Props for {@link SearchBar}.
*
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export type SearchBarProps = Partial<SearchBarBaseProps>;
/**
* Recommended search bar when you use the Search Provider or Search Context.
*
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
const { term, setTerm } = useSearch();
const handleChange = useCallback(
(newValue: string) => {
if (onChange) {
onChange(newValue);
} else {
setTerm(newValue);
}
},
[onChange, setTerm],
);
return <SearchBarBase value={term} onChange={handleChange} {...props} />;
};
@@ -1,18 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { SearchBar, SearchBarBase } from './SearchBar';
export type { SearchBarProps, SearchBarBaseProps } from './SearchBar';
@@ -1,363 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { TestApiProvider } from '@backstage/test-utils';
import { screen, render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import {
SearchContextProvider,
useSearch,
searchApiRef,
} from '@backstage/plugin-search-react';
import { SearchFilter } from './SearchFilter';
const SearchContextFilterSpy = ({ name }: { name: string }) => {
const { filters } = useSearch();
const value = filters[name];
return (
<span data-testid={`${name}-filter-spy`}>
{Array.isArray(value) ? value.join(',') : value}
</span>
);
};
describe('SearchFilter.Autocomplete', () => {
const query = jest.fn().mockResolvedValue({});
const emptySearchContext = {
term: '',
types: [],
filters: {},
};
const name = 'field';
const values = ['value1', 'value2'];
it('renders as expected', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete name={name} values={values} />
</SearchContextProvider>
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
const input = within(autocomplete).getByRole('textbox');
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
expect(screen.getByRole('option', { name: values[0] })).toBeInTheDocument();
expect(screen.getByRole('option', { name: values[1] })).toBeInTheDocument();
});
it('renders as expected with async values', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete name={name} values={async () => values} />
</SearchContextProvider>
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
const input = within(autocomplete).getByRole('textbox');
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
expect(screen.getByRole('option', { name: values[0] })).toBeInTheDocument();
expect(screen.getByRole('option', { name: values[1] })).toBeInTheDocument();
});
it('does not affect unrelated filter state', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider
initialState={{
...emptySearchContext,
...{ filters: { unrelated: 'value' } },
}}
>
<SearchFilter.Autocomplete name={name} values={values} />
<SearchContextFilterSpy name={name} />
<SearchContextFilterSpy name="unrelated" />
</SearchContextProvider>
</TestApiProvider>,
);
// The spy should show the initial value.
expect(screen.getByTestId('unrelated-filter-spy')).toHaveTextContent(
'value',
);
// Select a value from the autocomplete filter.
const autocomplete = screen.getByRole('combobox');
const input = within(autocomplete).getByRole('textbox');
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await userEvent.click(screen.getByRole('option', { name: values[1] }));
// Wait for the autocomplete filter's value to change.
await waitFor(() => {
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
values[1],
);
});
// Unrelated filter spy should maintain the same value.
expect(screen.getByTestId('unrelated-filter-spy')).toHaveTextContent(
'value',
);
});
describe('single', () => {
it('renders as expected with defaultValue', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete
name={name}
values={values}
defaultValue={values[1]}
/>
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
const input = within(autocomplete).getByRole('textbox');
await waitFor(() => {
expect(input).toHaveValue(values[1]);
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
values[1],
);
});
});
it('renders as expected with initial context', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider
initialState={{
...emptySearchContext,
...{ filters: { [name]: values[0] } },
}}
>
<SearchFilter.Autocomplete name={name} values={values} />
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
const input = within(autocomplete).getByRole('textbox');
await waitFor(() => {
expect(input).toHaveValue(values[0]);
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
values[0],
);
});
});
it('sets filter state when selecting a value', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete name={name} values={values} />
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</TestApiProvider>,
);
// Select the first option in the autocomplete.
const autocomplete = screen.getByRole('combobox');
const input = within(autocomplete).getByRole('textbox');
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await userEvent.click(screen.getByRole('option', { name: values[0] }));
// The value should be present in the context.
await waitFor(() => {
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
values[0],
);
});
// Click the "Clear" button to remove the value.
const clearButton = within(autocomplete).getByLabelText('Clear');
await userEvent.click(clearButton);
// That value should have been unset from the context.
await waitFor(() => {
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent('');
});
});
});
describe('multiple', () => {
it('renders as expected with defaultValue', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete
multiple
name={name}
values={values}
defaultValue={values}
/>
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</TestApiProvider>,
);
await waitFor(() => {
expect(screen.getByText(values[0])).toBeInTheDocument();
expect(screen.getByText(values[1])).toBeInTheDocument();
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
values.join(','),
);
});
});
it('renders as expected with initial context', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider
initialState={{
...emptySearchContext,
...{ filters: { [name]: values } },
}}
>
<SearchFilter.Autocomplete multiple name={name} values={values} />
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</TestApiProvider>,
);
await waitFor(() => {
expect(screen.getByText(values[0])).toBeInTheDocument();
expect(screen.getByText(values[1])).toBeInTheDocument();
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
values.join(','),
);
});
});
it('respects tag limit configuration', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete
multiple
limitTags={1}
name={name}
values={values}
/>
</SearchContextProvider>
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
const input = within(autocomplete).getByRole('textbox');
// Select the second value.
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await userEvent.click(screen.getByRole('option', { name: values[1] }));
await waitFor(() => {
expect(
screen.getByRole('button', { name: values[1] }),
).toBeInTheDocument();
});
// Select the first value.
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(
screen.getByRole('button', { name: values[0] }),
).toBeInTheDocument();
});
// Blur the field and only one tag should be shown with a +1.
input.blur();
expect(
screen.queryByRole('button', { name: values[0] }),
).not.toBeInTheDocument();
expect(screen.getByText('+1')).toBeInTheDocument();
});
it('sets filter state when selecting a value', async () => {
render(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete multiple name={name} values={values} />
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
// Select both values in the autocomplete.
const input = within(autocomplete).getByRole('textbox');
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await userEvent.click(screen.getByRole('option', { name: values[0] }));
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await userEvent.click(screen.getByRole('option', { name: values[1] }));
// Both options should be present in the context.
await waitFor(() => {
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
values.join(','),
);
});
// Click the "Clear" button to remove the value.
const clearButton = within(autocomplete).getByLabelText('Clear');
await userEvent.click(clearButton);
// There should be no content in the filter context.
await waitFor(() => {
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent('');
});
});
});
});
@@ -1,27 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { SearchFilterComponentProps } from '@backstage/plugin-search-react';
/**
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
filterSelectedOptions?: boolean;
limitTags?: number;
multiple?: boolean;
};
@@ -1,409 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchFilter } from './SearchFilter';
import { SearchContextProvider } from '@backstage/plugin-search-react';
import { useApi } from '@backstage/core-plugin-api';
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: jest.fn().mockReturnValue({}),
}));
describe('SearchFilter', () => {
const initialState = {
term: '',
filters: {},
types: [],
};
const label = 'Field';
const name = 'field';
const values = ['value1', 'value2'];
const filters = { unrelated: 'unrelated' };
const query = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ query: query });
afterAll(() => {
jest.resetAllMocks();
});
it('Check that element was rendered and received props', async () => {
const CustomFilter = (props: { name: string }) => <h6>{props.name}</h6>;
render(<SearchFilter name={name} component={CustomFilter} />);
expect(screen.getByRole('heading', { name })).toBeInTheDocument();
});
describe('Checkbox', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Checkbox label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
expect(
screen.getByRole('checkbox', { name: values[0] }),
).toBeInTheDocument();
expect(
screen.getByRole('checkbox', { name: values[1] }),
).toBeInTheDocument();
});
it('Renders correctly based on filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
filters: {
[name]: [values[1]],
},
}}
>
<SearchFilter.Checkbox label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
expect(
screen.getByRole('checkbox', { name: values[0] }),
).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: values[1] })).toBeChecked();
});
it('Renders correctly based on defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Checkbox
label={label}
name={name}
values={values}
defaultValue={[values[0]]}
/>
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
expect(screen.getByRole('checkbox', { name: values[0] })).toBeChecked();
expect(
screen.getByRole('checkbox', { name: values[1] }),
).not.toBeChecked();
});
it('Checking / unchecking a value sets filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Checkbox label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
const checkBox = screen.getByRole('checkbox', { name: values[0] });
// Check the box.
await userEvent.click(checkBox);
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters: { field: [values[0]] } }),
);
});
// Uncheck the box.
await userEvent.click(checkBox);
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters: {} }),
);
});
});
it('Checking / unchecking a value maintains unrelated filter state', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, filters }}>
<SearchFilter.Checkbox label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
const checkBox = screen.getByRole('checkbox', { name: values[0] });
// Check the box.
await userEvent.click(checkBox);
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { ...filters, field: [values[0]] },
}),
);
});
// Uncheck the box.
await userEvent.click(checkBox);
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters }),
);
});
});
});
describe('Select', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Select label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(
screen.getByRole('option', { name: values[0] }),
).toBeInTheDocument();
expect(
screen.getByRole('option', { name: values[1] }),
).toBeInTheDocument();
});
it('Renders values when provided asynchronously', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Select
label={label}
name={name}
values={async () => values}
/>
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByRole('button')).toBeInTheDocument();
expect(
screen.getByRole('button').getAttribute('aria-disabled'),
).not.toBe('true');
});
await userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(
screen.getByRole('option', { name: values[0] }),
).toBeInTheDocument();
expect(
screen.getByRole('option', { name: values[1] }),
).toBeInTheDocument();
});
it('Renders correctly based on filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
filters: {
[name]: values[0],
},
}}
>
<SearchFilter.Select label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
screen.getByRole('option', { name: values[1] }),
).not.toHaveAttribute('aria-selected');
expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute(
'aria-selected',
);
});
it('Renders correctly based on defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Select
name={name}
label={label}
values={values}
defaultValue={values[0]}
/>
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
screen.getByRole('option', { name: values[1] }),
).not.toHaveAttribute('aria-selected');
expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute(
'aria-selected',
);
});
it('Selecting a value sets filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Select label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
const button = screen.getByRole('button');
await userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { [name]: values[0] },
}),
);
});
await userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: {},
}),
);
});
});
it('Selecting a value maintains unrelated filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
filters,
}}
>
<SearchFilter.Select label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
const button = screen.getByRole('button');
await userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { ...filters, [name]: values[0] },
}),
);
});
await userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters }),
);
});
});
});
});
@@ -1,95 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ReactElement } from 'react';
import {
AutocompleteFilter,
CheckboxFilter,
SearchAutocompleteFilterProps,
SelectFilter,
} from '@backstage/plugin-search-react';
/**
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export type SearchFilterComponentProps = {
className?: string;
name: string;
label?: string;
/**
* Either an array of values directly, or an async function to return a list
* of values to be used in the filter. In the autocomplete filter, the last
* input value is provided as an input to allow values to be filtered. This
* function is debounced and values cached.
*/
values?: string[] | ((partial: string) => Promise<string[]>);
defaultValue?: string[] | string | null;
/**
* Debounce time in milliseconds, used when values is an async callback.
* Defaults to 250ms.
*/
valuesDebounceMs?: number;
};
/**
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export type SearchFilterWrapperProps = SearchFilterComponentProps & {
component: (props: SearchFilterComponentProps) => ReactElement;
debug?: boolean;
};
/**
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
const SearchFilter = ({
component: Element,
...props
}: SearchFilterWrapperProps) => <Element {...props} />;
/**
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
SearchFilter.Checkbox = (
props: Omit<SearchFilterWrapperProps, 'component'> &
SearchFilterComponentProps,
) => <SearchFilter {...props} component={CheckboxFilter} />;
/**
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
SearchFilter.Select = (
props: Omit<SearchFilterWrapperProps, 'component'> &
SearchFilterComponentProps,
) => <SearchFilter {...props} component={SelectFilter} />;
/**
* A control surface for a given filter field name, rendered as an autocomplete
* textfield. A hard-coded list of values may be provided, or an async function
* which returns values may be provided instead.
*
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
SearchFilter.Autocomplete = (props: SearchAutocompleteFilterProps) => (
<SearchFilter {...props} component={AutocompleteFilter} />
);
export { SearchFilter };
@@ -1,22 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { SearchFilter } from './SearchFilter';
export type {
SearchFilterComponentProps,
SearchFilterWrapperProps,
} from './SearchFilter';
export type { SearchAutocompleteFilterProps } from './SearchFilter.Autocomplete';
@@ -27,8 +27,8 @@ import {
import { makeStyles } from '@material-ui/core/styles';
import React, { ComponentType } from 'react';
import { rootRouteRef } from '../../plugin';
import { SearchBar } from '../SearchBar';
import {
SearchBar,
DefaultResultListItem,
searchApiRef,
MockSearchApi,
@@ -16,7 +16,7 @@
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { useLocation, useOutlet } from 'react-router';
import { useLocation } from 'react-router';
import { useSearch } from '@backstage/plugin-search-react';
import { SearchPage } from './SearchPage';
@@ -50,11 +50,6 @@ jest.mock('@backstage/plugin-search-react', () => ({
}),
}));
jest.mock('../LegacySearchPage', () => ({
...jest.requireActual('@backstage/plugin-search-react'),
LegacySearchPage: jest.fn().mockReturnValue('LegacySearchPageMock'),
}));
describe('SearchPage', () => {
const origReplaceState = window.history.replaceState;
@@ -96,13 +91,6 @@ describe('SearchPage', () => {
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 () => {
(useSearch as jest.Mock).mockReturnValueOnce({
term: 'bieber',
@@ -23,7 +23,6 @@ import {
useSearch,
} from '@backstage/plugin-search-react';
import { JsonObject } from '@backstage/types';
import { LegacySearchPage } from '../LegacySearchPage';
export const UrlUpdater = () => {
const location = useLocation();
@@ -96,7 +95,7 @@ export const SearchPage = () => {
return (
<SearchContextProvider>
<UrlUpdater />
{outlet || <LegacySearchPage />}
{outlet}
</SearchContextProvider>
);
};
@@ -1,56 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useSearch } from '@backstage/plugin-search-react';
import { SearchResultPager } from './SearchResultPager';
jest.mock('@backstage/plugin-search-react', () => ({
...jest.requireActual('@backstage/plugin-search-react'),
useSearch: jest.fn().mockReturnValue({
result: {},
}),
}));
describe('SearchResultPager', () => {
it('renders pager buttons', async () => {
const fetchNextPage = jest.fn();
const fetchPreviousPage = jest.fn();
(useSearch as jest.Mock).mockReturnValueOnce({
result: { loading: false, value: [] },
fetchNextPage,
fetchPreviousPage,
});
const { getByLabelText } = await renderInTestApp(<SearchResultPager />);
await waitFor(() => {
expect(getByLabelText('previous page')).toBeInTheDocument();
});
await userEvent.click(getByLabelText('previous page'));
expect(fetchPreviousPage).toBeCalled();
await waitFor(() => {
expect(getByLabelText('next page')).toBeInTheDocument();
});
await userEvent.click(getByLabelText('next page'));
expect(fetchNextPage).toBeCalled();
});
});
@@ -1,65 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { Button, makeStyles } from '@material-ui/core';
import ArrowBackIosIcon from '@material-ui/icons/ArrowBackIos';
import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
import React from 'react';
import { useSearch } from '@backstage/plugin-search-react';
const useStyles = makeStyles(theme => ({
root: {
display: 'flex',
justifyContent: 'space-between',
gap: theme.spacing(2),
margin: theme.spacing(2, 0),
},
}));
/**
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export const SearchResultPager = () => {
const { fetchNextPage, fetchPreviousPage } = useSearch();
const classes = useStyles();
if (!fetchNextPage && !fetchPreviousPage) {
return <></>;
}
return (
<nav arial-label="pagination navigation" className={classes.root}>
<Button
aria-label="previous page"
disabled={!fetchPreviousPage}
onClick={fetchPreviousPage}
startIcon={<ArrowBackIosIcon />}
>
Previous
</Button>
<Button
aria-label="next page"
disabled={!fetchNextPage}
onClick={fetchNextPage}
endIcon={<ArrowForwardIosIcon />}
>
Next
</Button>
</nav>
);
};
@@ -1,17 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { SearchResultPager } from './SearchResultPager';
+1 -21
View File
@@ -20,25 +20,8 @@
* @packageDocumentation
*/
export { Filters, FiltersButton } from './components/Filters';
export type {
FilterOptions,
FiltersState,
FiltersProps,
FiltersButtonProps,
} from './components/Filters';
export type { HomePageSearchBarProps } from './components/HomePageComponent';
export { SearchBar, SearchBarBase } from './components/SearchBar';
export type {
SearchBarBaseProps,
SearchBarProps,
} from './components/SearchBar';
export { SearchFilter } from './components/SearchFilter';
export type {
SearchAutocompleteFilterProps,
SearchFilterComponentProps,
SearchFilterWrapperProps,
} from './components/SearchFilter';
export {
SearchModal,
SearchModalProvider,
@@ -51,7 +34,6 @@ export type {
SearchModalValue,
} from './components/SearchModal';
export { SearchPage as Router } from './components/SearchPage';
export { SearchResultPager } from './components/SearchResultPager';
export { SearchType } from './components/SearchType';
export type {
SearchTypeAccordionProps,
@@ -63,11 +45,9 @@ export type { SidebarSearchProps } from './components/SidebarSearch';
export type { SidebarSearchModalProps } from './components/SidebarSearchModal';
export {
DefaultResultListItem,
HomePageSearchBar,
SearchPage,
searchPlugin as plugin,
searchPlugin,
SearchResult,
SidebarSearchModal,
} from './plugin';
+1 -17
View File
@@ -15,11 +15,7 @@
*/
import { SearchClient } from './apis';
import {
searchApiRef,
SearchResult as RealSearchResult,
DefaultResultListItem as RealDefaultResultListItem,
} from '@backstage/plugin-search-react';
import { searchApiRef } from '@backstage/plugin-search-react';
import {
createApiFactory,
createPlugin,
@@ -64,12 +60,6 @@ export const SearchPage = searchPlugin.provide(
}),
);
/**
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export const SearchResult = RealSearchResult;
/**
* @public
*/
@@ -85,12 +75,6 @@ export const SidebarSearchModal = searchPlugin.provide(
}),
);
/**
* @public
* @deprecated Import from `@backstage/plugin-search-react` instead.
*/
export const DefaultResultListItem = RealDefaultResultListItem;
/**
* @public
*/