@@ -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)
|
||||
@@ -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`
|
||||
@@ -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 don’t 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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user