Merge pull request #5927 from backstage/iameap/search-alpha-release
[Search] Initial, Alpha Release
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
# search-backend-node
|
||||
|
||||
This plugin is part of a suite of plugins that comprise the Backstage search
|
||||
platform, which is still very much under development. This plugin specifically
|
||||
is responsible for:
|
||||
platform. This particular plugin is responsible for all aspects of the search
|
||||
indexing process, including:
|
||||
|
||||
- Allowing other backend plugins to register the fact that they have documents
|
||||
that they'd like to be indexed by a search engine (known as `collators`)
|
||||
- Allowing other backend plugins to register the fact that they have metadata
|
||||
that they'd like to augment existing documents in the search index with
|
||||
(known as `decorators`)
|
||||
- Providing connections to search engines where actual document indices live
|
||||
and queries can be made.
|
||||
- Defining a mechanism for plugins to expose documents that they'd like to be
|
||||
indexed (called `collators`).
|
||||
- Defining a mechanism for plugins to add extra metadata to documents that the
|
||||
source plugin may not be aware of (known as `decorators`).
|
||||
- A scheduler that, at configurable intervals, compiles documents to be indexed
|
||||
and passes them to a search engine for indexing
|
||||
- Types for all of the above
|
||||
and passes them to a search engine for indexing.
|
||||
- A builder class to wire up all of the above.
|
||||
- Naturally, types for all of the above.
|
||||
|
||||
Documentation on how to develop and improve the search platform is currently
|
||||
centralized in the `search` plugin README.md.
|
||||
|
||||
@@ -74,4 +74,21 @@ describe('Scheduler', () => {
|
||||
expect(mockTask2).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should execute tasks on start', () => {
|
||||
const mockTask1 = jest.fn();
|
||||
const mockTask2 = jest.fn();
|
||||
|
||||
// Add tasks and interval to schedule
|
||||
testScheduler.addToSchedule(mockTask1, 2);
|
||||
testScheduler.addToSchedule(mockTask2, 2);
|
||||
|
||||
// Starts scheduling process
|
||||
testScheduler.start();
|
||||
|
||||
expect(mockTask1).toHaveBeenCalled();
|
||||
expect(mockTask2).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,8 @@ export class Scheduler {
|
||||
start() {
|
||||
this.logger.info('Starting all scheduled search tasks.');
|
||||
this.schedule.forEach(({ task, interval }) => {
|
||||
// Fire the task immediately, then schedule it.
|
||||
task();
|
||||
this.intervalTimeouts.push(
|
||||
setInterval(() => {
|
||||
task();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# search-backend
|
||||
|
||||
This plugin is part of a suite of plugins that comprise the Backstage search
|
||||
platform, which is still very much under development. This plugin specifically
|
||||
is responsible for exposing a JSON API for querying a search engine
|
||||
platform. This particular plugin responsible for exposing a JSON API for
|
||||
querying a search engine.
|
||||
|
||||
Documentation on how to develop and improve the search platform is currently
|
||||
centralized in the `search` plugin README.md.
|
||||
|
||||
+15
-12
@@ -1,23 +1,26 @@
|
||||
# Backstage Search
|
||||
|
||||
**This plugin is still under development.**
|
||||
A flexible, extensible search across your whole Backstage ecosystem.
|
||||
|
||||
You can follow the progress and contribute at the Backstage [Search Project Board](https://github.com/backstage/backstage/projects/6) or reach out to us in the [`#search` Discord channel](https://discord.com/channels/687207715902193673/770283289327566848).
|
||||
Development is ongoing. You can follow the progress and contribute at the Backstage [Search Project Board](https://github.com/backstage/backstage/projects/6) or reach out to us in the [`#search` Discord channel](https://discord.com/channels/687207715902193673/770283289327566848).
|
||||
|
||||
## Getting started
|
||||
|
||||
Run `yarn start` in the root directory, and then navigate to [/search](http://localhost:3000/search) to check out the plugin.
|
||||
Run `yarn dev` in the root directory, and then navigate to [/search](http://localhost:3000/search) to check out the plugin.
|
||||
|
||||
### Working on the search platform
|
||||
### Areas of Responsibility
|
||||
|
||||
The above search experience is 100% client-side and only looks at the Software Catalog. We are actively developing [a more complete search platform](https://backstage.io/docs/features/search/search-overview), which will replace the current experience when ready.
|
||||
This search plugin is primarily responsible for the following:
|
||||
|
||||
In order to work on this new search platform, you will need to be aware of the following:
|
||||
- Providing a `<SearchPage />` routable extension.
|
||||
- Exposing various search-related components (like `<SearchBar />`,
|
||||
`<SearchFilter />`, etc), which can be composed by a Backstage App or by
|
||||
other Backstage Plugins to power search experiences of all kinds.
|
||||
- Exposing a `<SearchContextProvider />`, which manages search state and API
|
||||
communication with the Backstage backend.
|
||||
|
||||
- **In-development app search route**: The new search platform will move the primary search page to the App-level, out of the search plugin. For now, to ensure the old experience is still easy to test, you can work on the new platform at `/search-next` instead of `/search`.
|
||||
- **App SearchPage Component**: You'll find a new search page in this plugin `SearchPageNext`. When sufficiently stable, we'll replace `SearchPage` with `SearchPageNext`
|
||||
- **Backend**: Don't forget, a lot of functionality will be made available in backend plugins:
|
||||
- `@backstage/plugin-search-backend-node`, which is responsible for the search index management
|
||||
- `@backstage/plugin-search-backend`, which is responsible for query processing
|
||||
Don't forget, a lot of functionality is available in backend plugins:
|
||||
|
||||
As you work, be sure not to break the existing, frontend-only search page.
|
||||
- `@backstage/plugin-search-backend-node`, which is responsible for the search
|
||||
index management
|
||||
- `@backstage/plugin-search-backend`, which is responsible for query processing
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CatalogApi } from '@backstage/plugin-catalog-react';
|
||||
|
||||
import { SearchClient } from './apis';
|
||||
|
||||
describe('apis', () => {
|
||||
@@ -29,7 +27,6 @@ describe('apis', () => {
|
||||
const baseUrl = 'https://base-url.com/';
|
||||
const getBaseUrl = jest.fn().mockResolvedValue(baseUrl);
|
||||
const client = new SearchClient({
|
||||
catalogApi: {} as CatalogApi,
|
||||
discoveryApi: { getBaseUrl },
|
||||
});
|
||||
|
||||
@@ -42,7 +39,7 @@ describe('apis', () => {
|
||||
});
|
||||
|
||||
it('Fetch is called with expected URL (including stringified Q params)', async () => {
|
||||
await client._alphaPerformSearch(query);
|
||||
await client.query(query);
|
||||
expect(getBaseUrl).toHaveBeenLastCalledWith('search/query');
|
||||
expect(fetch).toHaveBeenLastCalledWith(`${baseUrl}?term=&pageCursor=`);
|
||||
});
|
||||
@@ -50,6 +47,6 @@ describe('apis', () => {
|
||||
it('Resolves JSON from fetch response', async () => {
|
||||
const result = { loading: false, error: '', value: {} };
|
||||
json.mockReturnValueOnce(result);
|
||||
expect(await client._alphaPerformSearch(query)).toStrictEqual(result);
|
||||
expect(await client.query(query)).toStrictEqual(result);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
*/
|
||||
|
||||
import { createApiRef, DiscoveryApi } from '@backstage/core';
|
||||
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
|
||||
import { CatalogApi } from '@backstage/plugin-catalog-react';
|
||||
import { SearchQuery, SearchResultSet } from '@backstage/search-common';
|
||||
import qs from 'qs';
|
||||
|
||||
@@ -26,55 +23,18 @@ export const searchApiRef = createApiRef<SearchApi>({
|
||||
description: 'Used to make requests against the search API',
|
||||
});
|
||||
|
||||
export type Result = {
|
||||
name: string;
|
||||
description: string | undefined;
|
||||
owner: string | undefined;
|
||||
kind: string;
|
||||
lifecycle: string | undefined;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type SearchResults = Array<Result>;
|
||||
|
||||
export interface SearchApi {
|
||||
getSearchResult(): Promise<SearchResults>;
|
||||
_alphaPerformSearch(query: SearchQuery): Promise<SearchResultSet>;
|
||||
query(query: SearchQuery): Promise<SearchResultSet>;
|
||||
}
|
||||
|
||||
export class SearchClient implements SearchApi {
|
||||
private readonly catalogApi: CatalogApi;
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
|
||||
constructor(options: { catalogApi: CatalogApi; discoveryApi: DiscoveryApi }) {
|
||||
this.catalogApi = options.catalogApi;
|
||||
constructor(options: { discoveryApi: DiscoveryApi }) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
}
|
||||
|
||||
private async entities() {
|
||||
const entities = await this.catalogApi.getEntities();
|
||||
return entities.items.map((entity: Entity) => ({
|
||||
name: entity.metadata.name,
|
||||
description: entity.metadata.description,
|
||||
owner:
|
||||
typeof entity.spec?.owner === 'string' ? entity.spec?.owner : undefined,
|
||||
kind: entity.kind,
|
||||
lifecycle:
|
||||
typeof entity.spec?.lifecycle === 'string'
|
||||
? entity.spec?.lifecycle
|
||||
: undefined,
|
||||
url: `/catalog/${
|
||||
entity.metadata.namespace?.toLowerCase() || ENTITY_DEFAULT_NAMESPACE
|
||||
}/${entity.kind.toLowerCase()}/${entity.metadata.name}`,
|
||||
}));
|
||||
}
|
||||
|
||||
getSearchResult(): Promise<SearchResults> {
|
||||
return this.entities();
|
||||
}
|
||||
|
||||
// TODO: Productionalize as we implement search milestones.
|
||||
async _alphaPerformSearch(query: SearchQuery): Promise<SearchResultSet> {
|
||||
async query(query: SearchQuery): Promise<SearchResultSet> {
|
||||
const queryString = qs.stringify(query);
|
||||
const url = `${await this.discoveryApi.getBaseUrl(
|
||||
'search/query',
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
makeStyles,
|
||||
Typography,
|
||||
Divider,
|
||||
Card,
|
||||
CardHeader,
|
||||
Button,
|
||||
CardContent,
|
||||
Select,
|
||||
Checkbox,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
} from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
filters: {
|
||||
background: 'transparent',
|
||||
boxShadow: '0px 0px 0px 0px',
|
||||
},
|
||||
checkbox: {
|
||||
padding: theme.spacing(0, 1, 0, 1),
|
||||
},
|
||||
dropdown: {
|
||||
width: '100%',
|
||||
},
|
||||
}));
|
||||
|
||||
export type FiltersState = {
|
||||
selected: string;
|
||||
checked: Array<string>;
|
||||
};
|
||||
|
||||
export type FilterOptions = {
|
||||
kind: Array<string>;
|
||||
lifecycle: Array<string>;
|
||||
};
|
||||
|
||||
type FiltersProps = {
|
||||
filters: FiltersState;
|
||||
filterOptions: FilterOptions;
|
||||
resetFilters: () => void;
|
||||
updateSelected: (filter: string) => void;
|
||||
updateChecked: (filter: string) => void;
|
||||
};
|
||||
|
||||
export const Filters = ({
|
||||
filters,
|
||||
filterOptions,
|
||||
resetFilters,
|
||||
updateSelected,
|
||||
updateChecked,
|
||||
}: FiltersProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Card className={classes.filters}>
|
||||
<CardHeader
|
||||
title={<Typography variant="h6">Filters</Typography>}
|
||||
action={
|
||||
<Button color="primary" onClick={() => resetFilters()}>
|
||||
CLEAR ALL
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
{filterOptions.kind.length === 0 && filterOptions.lifecycle.length === 0 && (
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2">
|
||||
Filters cannot be applied to available results
|
||||
</Typography>
|
||||
</CardContent>
|
||||
)}
|
||||
{filterOptions.kind.length > 0 && (
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2">Kind</Typography>
|
||||
<Select
|
||||
id="outlined-select"
|
||||
onChange={(e: React.ChangeEvent<any>) =>
|
||||
updateSelected(e?.target?.value)
|
||||
}
|
||||
variant="outlined"
|
||||
className={classes.dropdown}
|
||||
value={filters.selected}
|
||||
>
|
||||
{filterOptions.kind.map(filter => (
|
||||
<MenuItem
|
||||
selected={filter === ''}
|
||||
dense
|
||||
key={filter}
|
||||
value={filter}
|
||||
>
|
||||
{filter}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</CardContent>
|
||||
)}
|
||||
{filterOptions.lifecycle.length > 0 && (
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2">Lifecycle</Typography>
|
||||
<List disablePadding dense>
|
||||
{filterOptions.lifecycle.map(filter => (
|
||||
<ListItem
|
||||
key={filter}
|
||||
dense
|
||||
button
|
||||
onClick={() => updateChecked(filter)}
|
||||
>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
disableRipple
|
||||
className={classes.checkbox}
|
||||
color="primary"
|
||||
checked={filters.checked.includes(filter)}
|
||||
tabIndex={-1}
|
||||
value={filter}
|
||||
name={filter}
|
||||
/>
|
||||
<ListItemText id={filter} primary={filter} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import FilterListIcon from '@material-ui/icons/FilterList';
|
||||
import { makeStyles, IconButton, Typography } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
filters: {
|
||||
width: '250px',
|
||||
display: 'flex',
|
||||
},
|
||||
icon: {
|
||||
margin: theme.spacing(-1, 0, 0, 0),
|
||||
},
|
||||
}));
|
||||
|
||||
type FiltersButtonProps = {
|
||||
numberOfSelectedFilters: number;
|
||||
handleToggleFilters: () => void;
|
||||
};
|
||||
|
||||
export const FiltersButton = ({
|
||||
numberOfSelectedFilters,
|
||||
handleToggleFilters,
|
||||
}: FiltersButtonProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={classes.filters}>
|
||||
<IconButton
|
||||
className={classes.icon}
|
||||
aria-label="settings"
|
||||
onClick={handleToggleFilters}
|
||||
>
|
||||
<FilterListIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6">
|
||||
Filters ({numberOfSelectedFilters ? numberOfSelectedFilters : 0})
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+3
-1
@@ -14,4 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { SearchPageNext } from './SearchPageNext';
|
||||
export { FiltersButton } from './FiltersButton';
|
||||
export { Filters } from './Filters';
|
||||
export type { FiltersState } from './Filters';
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { Paper } from '@material-ui/core';
|
||||
import InputBase from '@material-ui/core/InputBase';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import ClearButton from '@material-ui/icons/Clear';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
type SearchBarProps = {
|
||||
searchQuery: string;
|
||||
handleSearch: any;
|
||||
handleClearSearchBar: any;
|
||||
};
|
||||
|
||||
export const SearchBar = ({
|
||||
searchQuery,
|
||||
handleSearch,
|
||||
handleClearSearchBar,
|
||||
}: SearchBarProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
component="form"
|
||||
onSubmit={e => handleSearch(e)}
|
||||
className={classes.root}
|
||||
>
|
||||
<IconButton disabled type="submit" aria-label="search">
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
<InputBase
|
||||
className={classes.input}
|
||||
placeholder="Search in Backstage"
|
||||
value={searchQuery}
|
||||
onChange={e => handleSearch(e)}
|
||||
inputProps={{ 'aria-label': 'search backstage' }}
|
||||
/>
|
||||
<IconButton aria-label="search" onClick={() => handleClearSearchBar()}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Content, Header, Page, useQueryParamState } from '@backstage/core';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { SearchBar } from './LegacySearchBar';
|
||||
import { SearchResult } from './LegacySearchResult';
|
||||
|
||||
/**
|
||||
* @deprecated This SearchPage, powered directly by the Catalog API, will be
|
||||
* removed from a future release of this plugin.
|
||||
*/
|
||||
export const LegacySearchPage = () => {
|
||||
const [queryString, setQueryString] = useQueryParamState<string>('query');
|
||||
const [searchQuery, setSearchQuery] = useState(queryString ?? '');
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
setSearchQuery(event.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => setSearchQuery(queryString ?? ''), [queryString]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
setQueryString(searchQuery);
|
||||
},
|
||||
200,
|
||||
[searchQuery],
|
||||
);
|
||||
|
||||
const handleClearSearchBar = () => {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header title="Search" />
|
||||
<Content>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<SearchBar
|
||||
handleSearch={handleSearch}
|
||||
handleClearSearchBar={handleClearSearchBar}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<SearchResult
|
||||
searchQuery={(queryString ?? '').toLocaleLowerCase('en-US')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
EmptyState,
|
||||
Link,
|
||||
Progress,
|
||||
Table,
|
||||
TableColumn,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { Divider, Grid, makeStyles, Typography } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
|
||||
import { Filters, FiltersButton, FiltersState } from './Filters';
|
||||
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
|
||||
type Result = {
|
||||
name: string;
|
||||
description: string | undefined;
|
||||
owner: string | undefined;
|
||||
kind: string;
|
||||
lifecycle: string | undefined;
|
||||
url: string;
|
||||
};
|
||||
type SearchResults = Array<Result>;
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
searchQuery: {
|
||||
color: theme.palette.text.primary,
|
||||
background: theme.palette.background.default,
|
||||
borderRadius: '10%',
|
||||
},
|
||||
tableHeader: {
|
||||
margin: theme.spacing(1, 0, 0, 0),
|
||||
display: 'flex',
|
||||
},
|
||||
divider: {
|
||||
width: '1px',
|
||||
margin: theme.spacing(0, 2),
|
||||
padding: theme.spacing(2, 0),
|
||||
},
|
||||
}));
|
||||
|
||||
type SearchResultProps = {
|
||||
searchQuery?: string;
|
||||
};
|
||||
|
||||
type TableHeaderProps = {
|
||||
searchQuery?: string;
|
||||
numberOfSelectedFilters: number;
|
||||
numberOfResults: number;
|
||||
handleToggleFilters: () => void;
|
||||
};
|
||||
|
||||
// TODO: move out column to make the search result component more generic
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'name',
|
||||
highlight: true,
|
||||
render: (result: Partial<Result>) => (
|
||||
<Link to={result.url || ''}>{result.name}</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
field: 'description',
|
||||
},
|
||||
{
|
||||
title: 'Owner',
|
||||
field: 'owner',
|
||||
},
|
||||
{
|
||||
title: 'Kind',
|
||||
field: 'kind',
|
||||
},
|
||||
{
|
||||
title: 'LifeCycle',
|
||||
field: 'lifecycle',
|
||||
},
|
||||
];
|
||||
|
||||
const TableHeader = ({
|
||||
searchQuery,
|
||||
numberOfSelectedFilters,
|
||||
numberOfResults,
|
||||
handleToggleFilters,
|
||||
}: TableHeaderProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={classes.tableHeader}>
|
||||
<FiltersButton
|
||||
numberOfSelectedFilters={numberOfSelectedFilters}
|
||||
handleToggleFilters={handleToggleFilters}
|
||||
/>
|
||||
<Divider className={classes.divider} orientation="vertical" />
|
||||
<Grid item xs={12}>
|
||||
{searchQuery ? (
|
||||
<Typography variant="h6">
|
||||
{`${numberOfResults} `}
|
||||
{numberOfResults > 1 ? `results for ` : `result for `}
|
||||
<span className={classes.searchQuery}>"{searchQuery}"</span>{' '}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="h6">{`${numberOfResults} results`}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchResult = ({ searchQuery }: SearchResultProps) => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const [showFilters, toggleFilters] = useState(false);
|
||||
const [selectedFilters, setSelectedFilters] = useState<FiltersState>({
|
||||
selected: '',
|
||||
checked: [],
|
||||
});
|
||||
|
||||
const [filteredResults, setFilteredResults] = useState<SearchResults>([]);
|
||||
|
||||
const { loading, error, value: results } = useAsync(async () => {
|
||||
const entities = await catalogApi.getEntities();
|
||||
return entities.items.map((entity: Entity) => ({
|
||||
name: entity.metadata.name,
|
||||
description: entity.metadata.description,
|
||||
owner:
|
||||
typeof entity.spec?.owner === 'string' ? entity.spec?.owner : undefined,
|
||||
kind: entity.kind,
|
||||
lifecycle:
|
||||
typeof entity.spec?.lifecycle === 'string'
|
||||
? entity.spec?.lifecycle
|
||||
: undefined,
|
||||
url: `/catalog/${
|
||||
entity.metadata.namespace?.toLowerCase() || ENTITY_DEFAULT_NAMESPACE
|
||||
}/${entity.kind.toLowerCase()}/${entity.metadata.name}`,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (results) {
|
||||
let withFilters = results;
|
||||
|
||||
// apply filters
|
||||
|
||||
// filter on selected
|
||||
if (selectedFilters.selected !== '') {
|
||||
withFilters = results.filter((result: Result) =>
|
||||
selectedFilters.selected.includes(result.kind),
|
||||
);
|
||||
}
|
||||
|
||||
// filter on checked
|
||||
if (selectedFilters.checked.length > 0) {
|
||||
withFilters = withFilters.filter(
|
||||
(result: Result) =>
|
||||
result.lifecycle &&
|
||||
selectedFilters.checked.includes(result.lifecycle),
|
||||
);
|
||||
}
|
||||
|
||||
// filter on searchQuery
|
||||
if (searchQuery) {
|
||||
withFilters = withFilters.filter(
|
||||
(result: Result) =>
|
||||
result.name?.toLocaleLowerCase('en-US').includes(searchQuery) ||
|
||||
result.name
|
||||
?.toLocaleLowerCase('en-US')
|
||||
.includes(searchQuery.split(' ').join('-')) ||
|
||||
result.description
|
||||
?.toLocaleLowerCase('en-US')
|
||||
.includes(searchQuery),
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredResults(withFilters);
|
||||
}
|
||||
}, [selectedFilters, searchQuery, results]);
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error encountered while fetching search results. {error.toString()}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (!results || results.length === 0) {
|
||||
return <EmptyState missing="data" title="Sorry, no results were found" />;
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setSelectedFilters({
|
||||
selected: '',
|
||||
checked: [],
|
||||
});
|
||||
};
|
||||
|
||||
const updateSelected = (filter: string) => {
|
||||
setSelectedFilters(prevState => ({
|
||||
...prevState,
|
||||
selected: filter,
|
||||
}));
|
||||
};
|
||||
|
||||
const updateChecked = (filter: string) => {
|
||||
if (selectedFilters.checked.includes(filter)) {
|
||||
setSelectedFilters(prevState => ({
|
||||
...prevState,
|
||||
checked: prevState.checked.filter(item => item !== filter),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFilters(prevState => ({
|
||||
...prevState,
|
||||
checked: [...prevState.checked, filter],
|
||||
}));
|
||||
};
|
||||
|
||||
const filterOptions = results.reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.kind && acc.kind.indexOf(curr.kind) < 0) {
|
||||
acc.kind.push(curr.kind);
|
||||
}
|
||||
if (curr.lifecycle && acc.lifecycle.indexOf(curr.lifecycle) < 0) {
|
||||
acc.lifecycle.push(curr.lifecycle);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
kind: [] as Array<string>,
|
||||
lifecycle: [] as Array<string>,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container>
|
||||
{showFilters && (
|
||||
<Grid item xs={3}>
|
||||
<Filters
|
||||
filters={selectedFilters}
|
||||
filterOptions={filterOptions}
|
||||
resetFilters={resetFilters}
|
||||
updateSelected={updateSelected}
|
||||
updateChecked={updateChecked}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={showFilters ? 9 : 12}>
|
||||
<Table
|
||||
options={{ paging: true, pageSize: 20, search: false }}
|
||||
data={filteredResults}
|
||||
columns={columns}
|
||||
title={
|
||||
<TableHeader
|
||||
searchQuery={searchQuery}
|
||||
numberOfResults={filteredResults.length}
|
||||
numberOfSelectedFilters={
|
||||
(selectedFilters.selected !== '' ? 1 : 0) +
|
||||
selectedFilters.checked.length
|
||||
}
|
||||
handleToggleFilters={() => toggleFilters(!showFilters)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { SearchFilterNext } from './SearchFilterNext';
|
||||
export { LegacySearchPage } from './LegacySearchPage';
|
||||
+13
-13
@@ -20,14 +20,14 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useApi } from '@backstage/core';
|
||||
|
||||
import { SearchContextProvider } from '../SearchContext';
|
||||
import { SearchBarNext } from './SearchBarNext';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
jest.mock('@backstage/core', () => ({
|
||||
...jest.requireActual('@backstage/core'),
|
||||
useApi: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
describe('SearchBarNext', () => {
|
||||
describe('SearchBar', () => {
|
||||
const initialState = {
|
||||
term: '',
|
||||
pageCursor: '',
|
||||
@@ -38,8 +38,8 @@ describe('SearchBarNext', () => {
|
||||
const name = 'Search term';
|
||||
const term = 'term';
|
||||
|
||||
const _alphaPerformSearch = jest.fn().mockResolvedValue({});
|
||||
(useApi as jest.Mock).mockReturnValue({ _alphaPerformSearch });
|
||||
const query = jest.fn().mockResolvedValue({});
|
||||
(useApi as jest.Mock).mockReturnValue({ query });
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
@@ -48,7 +48,7 @@ describe('SearchBarNext', () => {
|
||||
it('Renders without exploding', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBarNext />
|
||||
<SearchBar />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('SearchBarNext', () => {
|
||||
it('Renders based on initial search', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
<SearchBarNext />
|
||||
<SearchBar />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('SearchBarNext', () => {
|
||||
it('Updates term state when text is entered', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBarNext />
|
||||
<SearchBar />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('SearchBarNext', () => {
|
||||
expect(textbox).toHaveValue(value);
|
||||
});
|
||||
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe('SearchBarNext', () => {
|
||||
it('Clear button clears term state', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
<SearchBarNext />
|
||||
<SearchBar />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('SearchBarNext', () => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue('');
|
||||
});
|
||||
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: '' }),
|
||||
);
|
||||
});
|
||||
@@ -120,7 +120,7 @@ describe('SearchBarNext', () => {
|
||||
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBarNext debounceTime={debounceTime} />
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('SearchBarNext', () => {
|
||||
|
||||
userEvent.type(textbox, value);
|
||||
|
||||
expect(_alphaPerformSearch).not.toHaveBeenLastCalledWith(
|
||||
expect(query).not.toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('SearchBarNext', () => {
|
||||
expect(textbox).toHaveValue(value);
|
||||
});
|
||||
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,56 +14,54 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { Paper } from '@material-ui/core';
|
||||
import InputBase from '@material-ui/core/InputBase';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import React, { ChangeEvent, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { InputBase, InputAdornment, IconButton } from '@material-ui/core';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import ClearButton from '@material-ui/icons/Clear';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
import { useSearch } from '../SearchContext';
|
||||
|
||||
type SearchBarProps = {
|
||||
searchQuery: string;
|
||||
handleSearch: any;
|
||||
handleClearSearchBar: any;
|
||||
type Props = {
|
||||
className?: string;
|
||||
debounceTime?: number;
|
||||
};
|
||||
|
||||
export const SearchBar = ({
|
||||
searchQuery,
|
||||
handleSearch,
|
||||
handleClearSearchBar,
|
||||
}: SearchBarProps) => {
|
||||
const classes = useStyles();
|
||||
export const SearchBar = ({ className, debounceTime = 0 }: Props) => {
|
||||
const { term, setTerm } = useSearch();
|
||||
const [value, setValue] = useState<string>(term);
|
||||
|
||||
useDebounce(() => setTerm(value), debounceTime, [value]);
|
||||
|
||||
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleClear = () => setValue('');
|
||||
|
||||
return (
|
||||
<Paper
|
||||
component="form"
|
||||
onSubmit={e => handleSearch(e)}
|
||||
className={classes.root}
|
||||
>
|
||||
<IconButton disabled type="submit" aria-label="search">
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
<InputBase
|
||||
className={classes.input}
|
||||
placeholder="Search in Backstage"
|
||||
value={searchQuery}
|
||||
onChange={e => handleSearch(e)}
|
||||
inputProps={{ 'aria-label': 'search backstage' }}
|
||||
/>
|
||||
<IconButton aria-label="search" onClick={() => handleClearSearchBar()}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
<InputBase
|
||||
className={className}
|
||||
data-testid="search-bar-next"
|
||||
fullWidth
|
||||
placeholder="Search in Backstage"
|
||||
value={value}
|
||||
onChange={handleQuery}
|
||||
inputProps={{ 'aria-label': 'Search term' }}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query term" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Clear term" onClick={handleClear}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ChangeEvent, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { InputBase, InputAdornment, IconButton } from '@material-ui/core';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import ClearButton from '@material-ui/icons/Clear';
|
||||
|
||||
import { useSearch } from '../SearchContext';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
debounceTime?: number;
|
||||
};
|
||||
|
||||
export const SearchBarNext = ({ className, debounceTime = 0 }: Props) => {
|
||||
const { term, setTerm } = useSearch();
|
||||
const [value, setValue] = useState<string>(term);
|
||||
|
||||
useDebounce(() => setTerm(value), debounceTime, [value]);
|
||||
|
||||
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleClear = () => setValue('');
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
className={className}
|
||||
data-testid="search-bar-next"
|
||||
fullWidth
|
||||
placeholder="Search in Backstage"
|
||||
value={value}
|
||||
onChange={handleQuery}
|
||||
inputProps={{ 'aria-label': 'Search term' }}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query term" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Clear term" onClick={handleClear}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -28,7 +28,7 @@ jest.mock('@backstage/core', () => ({
|
||||
}));
|
||||
|
||||
describe('SearchContext', () => {
|
||||
const _alphaPerformSearch = jest.fn();
|
||||
const query = jest.fn();
|
||||
|
||||
const wrapper = ({ children, initialState }: any) => (
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
@@ -44,8 +44,8 @@ describe('SearchContext', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
_alphaPerformSearch.mockResolvedValue({});
|
||||
(useApi as jest.Mock).mockReturnValue({ _alphaPerformSearch });
|
||||
query.mockResolvedValue({});
|
||||
(useApi as jest.Mock).mockReturnValue({ query: query });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -138,7 +138,7 @@ describe('SearchContext', () => {
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
|
||||
expect(query).toHaveBeenLastCalledWith({
|
||||
...initialState,
|
||||
term,
|
||||
});
|
||||
@@ -162,7 +162,7 @@ describe('SearchContext', () => {
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
|
||||
expect(query).toHaveBeenLastCalledWith({
|
||||
...initialState,
|
||||
filters,
|
||||
});
|
||||
@@ -186,7 +186,7 @@ describe('SearchContext', () => {
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
|
||||
expect(query).toHaveBeenLastCalledWith({
|
||||
...initialState,
|
||||
pageCursor,
|
||||
});
|
||||
@@ -210,7 +210,7 @@ describe('SearchContext', () => {
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
|
||||
expect(query).toHaveBeenLastCalledWith({
|
||||
...initialState,
|
||||
types,
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export const SearchContextProvider = ({
|
||||
|
||||
const result = useAsync(
|
||||
() =>
|
||||
searchApi._alphaPerformSearch({
|
||||
searchApi.query({
|
||||
term,
|
||||
filters,
|
||||
pageCursor,
|
||||
|
||||
+23
-23
@@ -19,7 +19,7 @@ import { screen, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useApi } from '@backstage/core';
|
||||
|
||||
import { SearchFilterNext } from './SearchFilterNext';
|
||||
import { SearchFilter } from './SearchFilter';
|
||||
import { SearchContextProvider } from '../SearchContext';
|
||||
|
||||
jest.mock('@backstage/core', () => ({
|
||||
@@ -27,7 +27,7 @@ jest.mock('@backstage/core', () => ({
|
||||
useApi: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
describe('SearchFilterNext', () => {
|
||||
describe('SearchFilter', () => {
|
||||
const initialState = {
|
||||
term: '',
|
||||
filters: {},
|
||||
@@ -39,8 +39,8 @@ describe('SearchFilterNext', () => {
|
||||
const values = ['value1', 'value2'];
|
||||
const filters = { unrelated: 'unrelated' };
|
||||
|
||||
const _alphaPerformSearch = jest.fn().mockResolvedValue({});
|
||||
(useApi as jest.Mock).mockReturnValue({ _alphaPerformSearch });
|
||||
const query = jest.fn().mockResolvedValue({});
|
||||
(useApi as jest.Mock).mockReturnValue({ query: query });
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
@@ -49,7 +49,7 @@ describe('SearchFilterNext', () => {
|
||||
it('Check that element was rendered and received props', async () => {
|
||||
const CustomFilter = (props: { name: string }) => <h6>{props.name}</h6>;
|
||||
|
||||
render(<SearchFilterNext name={name} component={CustomFilter} />);
|
||||
render(<SearchFilter name={name} component={CustomFilter} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name })).toBeInTheDocument();
|
||||
});
|
||||
@@ -58,7 +58,7 @@ describe('SearchFilterNext', () => {
|
||||
it('Renders field name and values when provided as props', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilterNext.Checkbox name={name} values={values} />
|
||||
<SearchFilter.Checkbox name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('SearchFilterNext', () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SearchFilterNext.Checkbox name={name} values={values} />
|
||||
<SearchFilter.Checkbox name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('SearchFilterNext', () => {
|
||||
it('Renders correctly based on defaultValue', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilterNext.Checkbox
|
||||
<SearchFilter.Checkbox
|
||||
name={name}
|
||||
values={values}
|
||||
defaultValue={[values[0]]}
|
||||
@@ -122,7 +122,7 @@ describe('SearchFilterNext', () => {
|
||||
it('Checking / unchecking a value sets filter state', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilterNext.Checkbox name={name} values={values} />
|
||||
<SearchFilter.Checkbox name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('SearchFilterNext', () => {
|
||||
// Check the box.
|
||||
userEvent.click(checkBox);
|
||||
await waitFor(() => {
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ filters: { field: [values[0]] } }),
|
||||
);
|
||||
});
|
||||
@@ -143,7 +143,7 @@ describe('SearchFilterNext', () => {
|
||||
// Uncheck the box.
|
||||
userEvent.click(checkBox);
|
||||
await waitFor(() => {
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ filters: {} }),
|
||||
);
|
||||
});
|
||||
@@ -152,7 +152,7 @@ describe('SearchFilterNext', () => {
|
||||
it('Checking / unchecking a value maintains unrelated filter state', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={{ ...initialState, filters }}>
|
||||
<SearchFilterNext.Checkbox name={name} values={values} />
|
||||
<SearchFilter.Checkbox name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -165,7 +165,7 @@ describe('SearchFilterNext', () => {
|
||||
// Check the box.
|
||||
userEvent.click(checkBox);
|
||||
await waitFor(() => {
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filters: { ...filters, field: [values[0]] },
|
||||
}),
|
||||
@@ -175,7 +175,7 @@ describe('SearchFilterNext', () => {
|
||||
// Uncheck the box.
|
||||
userEvent.click(checkBox);
|
||||
await waitFor(() => {
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ filters }),
|
||||
);
|
||||
});
|
||||
@@ -186,7 +186,7 @@ describe('SearchFilterNext', () => {
|
||||
it('Renders field name and values when provided as props', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilterNext.Select name={name} values={values} />
|
||||
<SearchFilter.Select name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -218,7 +218,7 @@ describe('SearchFilterNext', () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SearchFilterNext.Select name={name} values={values} />
|
||||
<SearchFilter.Select name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -247,7 +247,7 @@ describe('SearchFilterNext', () => {
|
||||
it('Renders correctly based on defaultValue', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilterNext.Select
|
||||
<SearchFilter.Select
|
||||
name={name}
|
||||
values={values}
|
||||
defaultValue={values[0]}
|
||||
@@ -280,7 +280,7 @@ describe('SearchFilterNext', () => {
|
||||
it('Selecting a value sets filter state', async () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilterNext.Select name={name} values={values} />
|
||||
<SearchFilter.Select name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -299,7 +299,7 @@ describe('SearchFilterNext', () => {
|
||||
userEvent.click(screen.getByRole('option', { name: values[0] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filters: { [name]: values[0] },
|
||||
}),
|
||||
@@ -315,7 +315,7 @@ describe('SearchFilterNext', () => {
|
||||
userEvent.click(screen.getByRole('option', { name: 'All' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filters: {},
|
||||
}),
|
||||
@@ -331,7 +331,7 @@ describe('SearchFilterNext', () => {
|
||||
filters,
|
||||
}}
|
||||
>
|
||||
<SearchFilterNext.Select name={name} values={values} />
|
||||
<SearchFilter.Select name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
@@ -350,7 +350,7 @@ describe('SearchFilterNext', () => {
|
||||
userEvent.click(screen.getByRole('option', { name: values[0] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filters: { ...filters, [name]: values[0] },
|
||||
}),
|
||||
@@ -366,7 +366,7 @@ describe('SearchFilterNext', () => {
|
||||
userEvent.click(screen.getByRole('option', { name: 'All' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ filters }),
|
||||
);
|
||||
});
|
||||
+14
-6
@@ -164,16 +164,24 @@ const SelectFilter = ({
|
||||
);
|
||||
};
|
||||
|
||||
const SearchFilterNext = ({ component: Element, ...props }: Props) => (
|
||||
const SearchFilter = ({ component: Element, ...props }: Props) => (
|
||||
<Element {...props} />
|
||||
);
|
||||
|
||||
SearchFilterNext.Checkbox = (props: Omit<Props, 'component'> & Component) => (
|
||||
<SearchFilterNext {...props} component={CheckboxFilter} />
|
||||
SearchFilter.Checkbox = (props: Omit<Props, 'component'> & Component) => (
|
||||
<SearchFilter {...props} component={CheckboxFilter} />
|
||||
);
|
||||
|
||||
SearchFilterNext.Select = (props: Omit<Props, 'component'> & Component) => (
|
||||
<SearchFilterNext {...props} component={SelectFilter} />
|
||||
SearchFilter.Select = (props: Omit<Props, 'component'> & Component) => (
|
||||
<SearchFilter {...props} component={SelectFilter} />
|
||||
);
|
||||
|
||||
export { SearchFilterNext };
|
||||
/**
|
||||
* @deprecated This component was used for rapid prototyping of the Backstage
|
||||
* Search platform. Now that the API has stabilized, you should use the
|
||||
* <SearchFilter /> component instead. This component will be removed in an
|
||||
* upcoming release.
|
||||
*/
|
||||
const SearchFilterNext = SearchFilter;
|
||||
|
||||
export { SearchFilter, SearchFilterNext };
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { SearchBarNext } from './SearchBarNext';
|
||||
export { SearchFilter, SearchFilterNext } from './SearchFilter';
|
||||
+19
-7
@@ -16,17 +16,17 @@
|
||||
|
||||
import React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { useLocation, Outlet } from 'react-router';
|
||||
import { useLocation, useOutlet } from 'react-router';
|
||||
|
||||
import { useSearch, SearchContextProvider } from '../SearchContext';
|
||||
import { SearchPageNext } from './';
|
||||
import { SearchPage } from './';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useLocation: jest.fn().mockReturnValue({
|
||||
search: '',
|
||||
}),
|
||||
Outlet: jest.fn().mockReturnValue(null),
|
||||
useOutlet: jest.fn().mockReturnValue('Route Children'),
|
||||
}));
|
||||
|
||||
jest.mock('../SearchContext', () => ({
|
||||
@@ -42,6 +42,11 @@ jest.mock('../SearchContext', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../LegacySearchPage', () => ({
|
||||
...jest.requireActual('../SearchContext'),
|
||||
LegacySearchPage: jest.fn().mockReturnValue('LegacySearchPageMock'),
|
||||
}));
|
||||
|
||||
describe('SearchPage', () => {
|
||||
const origReplaceState = window.history.replaceState;
|
||||
|
||||
@@ -68,7 +73,7 @@ describe('SearchPage', () => {
|
||||
});
|
||||
|
||||
// When we render the page...
|
||||
await renderInTestApp(<SearchPageNext />);
|
||||
await renderInTestApp(<SearchPage />);
|
||||
|
||||
// Then search context should be initialized with these values...
|
||||
const calls = (SearchContextProvider as jest.Mock).mock.calls[0];
|
||||
@@ -80,9 +85,16 @@ describe('SearchPage', () => {
|
||||
});
|
||||
|
||||
it('renders provided router element', async () => {
|
||||
await renderInTestApp(<SearchPageNext />);
|
||||
const { getByText } = await renderInTestApp(<SearchPage />);
|
||||
|
||||
expect(Outlet).toHaveBeenCalled();
|
||||
expect(getByText('Route Children')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders legacy search when no router children are provided', async () => {
|
||||
(useOutlet as jest.Mock).mockReturnValueOnce(null);
|
||||
const { getByText } = await renderInTestApp(<SearchPage />);
|
||||
|
||||
expect(getByText('LegacySearchPageMock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('replaces window history with expected query parameters', async () => {
|
||||
@@ -96,7 +108,7 @@ describe('SearchPage', () => {
|
||||
'?query=bieber&types[]=software-catalog&pageCursor=page2-or-something&filters[anyKey]=anyValue',
|
||||
);
|
||||
|
||||
await renderInTestApp(<SearchPageNext />);
|
||||
await renderInTestApp(<SearchPage />);
|
||||
|
||||
const calls = (window.history.replaceState as jest.Mock).mock.calls[0];
|
||||
expect(calls[2]).toContain(expectedLocation);
|
||||
@@ -13,55 +13,57 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Content, Header, Page, useQueryParamState } from '@backstage/core';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { SearchBar } from '../SearchBar';
|
||||
import { SearchResult } from '../SearchResult';
|
||||
|
||||
import React from 'react';
|
||||
import qs from 'qs';
|
||||
import { useLocation, useOutlet } from 'react-router';
|
||||
import { SearchContextProvider, useSearch } from '../SearchContext';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { LegacySearchPage } from '../LegacySearchPage';
|
||||
|
||||
export const UrlUpdater = () => {
|
||||
const { term, types, pageCursor, filters } = useSearch();
|
||||
|
||||
const newParams = qs.stringify(
|
||||
{
|
||||
query: term,
|
||||
types,
|
||||
pageCursor,
|
||||
filters,
|
||||
},
|
||||
{ arrayFormat: 'brackets' },
|
||||
);
|
||||
const newUrl = `${window.location.pathname}?${newParams}`;
|
||||
|
||||
// We directly manipulate window history here in order to not re-render
|
||||
// infinitely (state => location => state => etc). The intention of this
|
||||
// code is just to ensure the right query/filters are loaded when a user
|
||||
// clicks the "back" button after clicking a result.
|
||||
window.history.replaceState(null, document.title, newUrl);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SearchPage = () => {
|
||||
const [queryString, setQueryString] = useQueryParamState<string>('query');
|
||||
const [searchQuery, setSearchQuery] = useState(queryString ?? '');
|
||||
const location = useLocation();
|
||||
const outlet = useOutlet();
|
||||
const query = qs.parse(location.search.substring(1), { arrayLimit: 0 }) || {};
|
||||
const filters = (query.filters as JsonObject) || {};
|
||||
const queryString = (query.query as string) || '';
|
||||
const pageCursor = (query.pageCursor as string) || '';
|
||||
const types = (query.types as string[]) || [];
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
setSearchQuery(event.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => setSearchQuery(queryString ?? ''), [queryString]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
setQueryString(searchQuery);
|
||||
},
|
||||
200,
|
||||
[searchQuery],
|
||||
);
|
||||
|
||||
const handleClearSearchBar = () => {
|
||||
setSearchQuery('');
|
||||
const initialState = {
|
||||
term: queryString || '',
|
||||
types,
|
||||
pageCursor,
|
||||
filters,
|
||||
};
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header title="Search" />
|
||||
<Content>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<SearchBar
|
||||
handleSearch={handleSearch}
|
||||
handleClearSearchBar={handleClearSearchBar}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<SearchResult
|
||||
searchQuery={(queryString ?? '').toLocaleLowerCase('en-US')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<UrlUpdater />
|
||||
{outlet || <LegacySearchPage />}
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import qs from 'qs';
|
||||
import { Outlet, useLocation } from 'react-router';
|
||||
import { SearchContextProvider, useSearch } from '../SearchContext';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
|
||||
export const UrlUpdater = () => {
|
||||
const { term, types, pageCursor, filters } = useSearch();
|
||||
|
||||
const newParams = qs.stringify(
|
||||
{
|
||||
query: term,
|
||||
types,
|
||||
pageCursor,
|
||||
filters,
|
||||
},
|
||||
{ arrayFormat: 'brackets' },
|
||||
);
|
||||
const newUrl = `${window.location.pathname}?${newParams}`;
|
||||
|
||||
// We directly manipulate window history here in order to not re-render
|
||||
// infinitely (state => location => state => etc). The intention of this
|
||||
// code is just to ensure the right query/filters are loaded when a user
|
||||
// clicks the "back" button after clicking a result.
|
||||
window.history.replaceState(null, document.title, newUrl);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SearchPageNext = () => {
|
||||
const location = useLocation();
|
||||
const query = qs.parse(location.search.substring(1), { arrayLimit: 0 }) || {};
|
||||
const filters = (query.filters as JsonObject) || {};
|
||||
const queryString = (query.query as string) || '';
|
||||
const pageCursor = (query.pageCursor as string) || '';
|
||||
const types = (query.types as string[]) || [];
|
||||
|
||||
const initialState = {
|
||||
term: queryString || '',
|
||||
types,
|
||||
pageCursor,
|
||||
filters,
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<UrlUpdater />
|
||||
<Outlet />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
+7
-13
@@ -17,7 +17,7 @@
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
|
||||
import { SearchResultNext } from './SearchResultNext';
|
||||
import { SearchResult } from './SearchResult';
|
||||
import { useSearch } from '../SearchContext';
|
||||
|
||||
jest.mock('../SearchContext', () => ({
|
||||
@@ -27,15 +27,13 @@ jest.mock('../SearchContext', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SearchResultNext', () => {
|
||||
describe('SearchResult', () => {
|
||||
it('Progress rendered on Loading state', async () => {
|
||||
(useSearch as jest.Mock).mockReturnValueOnce({
|
||||
result: { loading: true },
|
||||
});
|
||||
|
||||
const { getByRole } = render(
|
||||
<SearchResultNext>{() => <></>}</SearchResultNext>,
|
||||
);
|
||||
const { getByRole } = render(<SearchResult>{() => <></>}</SearchResult>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('progressbar')).toBeInTheDocument();
|
||||
@@ -48,9 +46,7 @@ describe('SearchResultNext', () => {
|
||||
result: { loading: false, error },
|
||||
});
|
||||
|
||||
const { getByRole } = render(
|
||||
<SearchResultNext>{() => <></>}</SearchResultNext>,
|
||||
);
|
||||
const { getByRole } = render(<SearchResult>{() => <></>}</SearchResult>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('alert')).toHaveTextContent(
|
||||
@@ -64,9 +60,7 @@ describe('SearchResultNext', () => {
|
||||
result: { loading: false, error: '', value: undefined },
|
||||
});
|
||||
|
||||
const { getByRole } = render(
|
||||
<SearchResultNext>{() => <></>}</SearchResultNext>,
|
||||
);
|
||||
const { getByRole } = render(<SearchResult>{() => <></>}</SearchResult>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -81,12 +75,12 @@ describe('SearchResultNext', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<SearchResultNext>
|
||||
<SearchResult>
|
||||
{({ results }) => {
|
||||
expect(results).toEqual([]);
|
||||
return <></>;
|
||||
}}
|
||||
</SearchResultNext>,
|
||||
</SearchResult>,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -13,162 +13,23 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
EmptyState,
|
||||
Link,
|
||||
Progress,
|
||||
Table,
|
||||
TableColumn,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { Divider, Grid, makeStyles, Typography } from '@material-ui/core';
|
||||
|
||||
import React from 'react';
|
||||
import { EmptyState, Progress } from '@backstage/core';
|
||||
import { SearchResult } from '@backstage/search-common';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { Result, SearchResults, searchApiRef } from '../../apis';
|
||||
|
||||
import { Filters, FiltersButton, FiltersState } from '../Filters';
|
||||
import { useSearch } from '../SearchContext';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
searchQuery: {
|
||||
color: theme.palette.text.primary,
|
||||
background: theme.palette.background.default,
|
||||
borderRadius: '10%',
|
||||
},
|
||||
tableHeader: {
|
||||
margin: theme.spacing(1, 0, 0, 0),
|
||||
display: 'flex',
|
||||
},
|
||||
divider: {
|
||||
width: '1px',
|
||||
margin: theme.spacing(0, 2),
|
||||
padding: theme.spacing(2, 0),
|
||||
},
|
||||
}));
|
||||
|
||||
type SearchResultProps = {
|
||||
searchQuery?: string;
|
||||
type Props = {
|
||||
children: (results: { results: SearchResult[] }) => JSX.Element;
|
||||
};
|
||||
|
||||
type TableHeaderProps = {
|
||||
searchQuery?: string;
|
||||
numberOfSelectedFilters: number;
|
||||
numberOfResults: number;
|
||||
handleToggleFilters: () => void;
|
||||
};
|
||||
const SearchResultComponent = ({ children }: Props) => {
|
||||
const {
|
||||
result: { loading, error, value },
|
||||
} = useSearch();
|
||||
|
||||
// TODO: move out column to make the search result component more generic
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'name',
|
||||
highlight: true,
|
||||
render: (result: Partial<Result>) => (
|
||||
<Link to={result.url || ''}>{result.name}</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
field: 'description',
|
||||
},
|
||||
{
|
||||
title: 'Owner',
|
||||
field: 'owner',
|
||||
},
|
||||
{
|
||||
title: 'Kind',
|
||||
field: 'kind',
|
||||
},
|
||||
{
|
||||
title: 'LifeCycle',
|
||||
field: 'lifecycle',
|
||||
},
|
||||
];
|
||||
|
||||
const TableHeader = ({
|
||||
searchQuery,
|
||||
numberOfSelectedFilters,
|
||||
numberOfResults,
|
||||
handleToggleFilters,
|
||||
}: TableHeaderProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={classes.tableHeader}>
|
||||
<FiltersButton
|
||||
numberOfSelectedFilters={numberOfSelectedFilters}
|
||||
handleToggleFilters={handleToggleFilters}
|
||||
/>
|
||||
<Divider className={classes.divider} orientation="vertical" />
|
||||
<Grid item xs={12}>
|
||||
{searchQuery ? (
|
||||
<Typography variant="h6">
|
||||
{`${numberOfResults} `}
|
||||
{numberOfResults > 1 ? `results for ` : `result for `}
|
||||
<span className={classes.searchQuery}>"{searchQuery}"</span>{' '}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="h6">{`${numberOfResults} results`}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchResult = ({ searchQuery }: SearchResultProps) => {
|
||||
const searchApi = useApi(searchApiRef);
|
||||
|
||||
const [showFilters, toggleFilters] = useState(false);
|
||||
const [selectedFilters, setSelectedFilters] = useState<FiltersState>({
|
||||
selected: '',
|
||||
checked: [],
|
||||
});
|
||||
|
||||
const [filteredResults, setFilteredResults] = useState<SearchResults>([]);
|
||||
|
||||
const { loading, error, value: results } = useAsync(() => {
|
||||
return searchApi.getSearchResult();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (results) {
|
||||
let withFilters = results;
|
||||
|
||||
// apply filters
|
||||
|
||||
// filter on selected
|
||||
if (selectedFilters.selected !== '') {
|
||||
withFilters = results.filter((result: Result) =>
|
||||
selectedFilters.selected.includes(result.kind),
|
||||
);
|
||||
}
|
||||
|
||||
// filter on checked
|
||||
if (selectedFilters.checked.length > 0) {
|
||||
withFilters = withFilters.filter(
|
||||
(result: Result) =>
|
||||
result.lifecycle &&
|
||||
selectedFilters.checked.includes(result.lifecycle),
|
||||
);
|
||||
}
|
||||
|
||||
// filter on searchQuery
|
||||
if (searchQuery) {
|
||||
withFilters = withFilters.filter(
|
||||
(result: Result) =>
|
||||
result.name?.toLocaleLowerCase('en-US').includes(searchQuery) ||
|
||||
result.name
|
||||
?.toLocaleLowerCase('en-US')
|
||||
.includes(searchQuery.split(' ').join('-')) ||
|
||||
result.description
|
||||
?.toLocaleLowerCase('en-US')
|
||||
.includes(searchQuery),
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredResults(withFilters);
|
||||
}
|
||||
}, [selectedFilters, searchQuery, results]);
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
@@ -179,88 +40,12 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => {
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (!results || results.length === 0) {
|
||||
|
||||
if (!value) {
|
||||
return <EmptyState missing="data" title="Sorry, no results were found" />;
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setSelectedFilters({
|
||||
selected: '',
|
||||
checked: [],
|
||||
});
|
||||
};
|
||||
|
||||
const updateSelected = (filter: string) => {
|
||||
setSelectedFilters(prevState => ({
|
||||
...prevState,
|
||||
selected: filter,
|
||||
}));
|
||||
};
|
||||
|
||||
const updateChecked = (filter: string) => {
|
||||
if (selectedFilters.checked.includes(filter)) {
|
||||
setSelectedFilters(prevState => ({
|
||||
...prevState,
|
||||
checked: prevState.checked.filter(item => item !== filter),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFilters(prevState => ({
|
||||
...prevState,
|
||||
checked: [...prevState.checked, filter],
|
||||
}));
|
||||
};
|
||||
|
||||
const filterOptions = results.reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.kind && acc.kind.indexOf(curr.kind) < 0) {
|
||||
acc.kind.push(curr.kind);
|
||||
}
|
||||
if (curr.lifecycle && acc.lifecycle.indexOf(curr.lifecycle) < 0) {
|
||||
acc.lifecycle.push(curr.lifecycle);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
kind: [] as Array<string>,
|
||||
lifecycle: [] as Array<string>,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container>
|
||||
{showFilters && (
|
||||
<Grid item xs={3}>
|
||||
<Filters
|
||||
filters={selectedFilters}
|
||||
filterOptions={filterOptions}
|
||||
resetFilters={resetFilters}
|
||||
updateSelected={updateSelected}
|
||||
updateChecked={updateChecked}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={showFilters ? 9 : 12}>
|
||||
<Table
|
||||
options={{ paging: true, pageSize: 20, search: false }}
|
||||
data={filteredResults}
|
||||
columns={columns}
|
||||
title={
|
||||
<TableHeader
|
||||
searchQuery={searchQuery}
|
||||
numberOfResults={filteredResults.length}
|
||||
numberOfSelectedFilters={
|
||||
(selectedFilters.selected !== '' ? 1 : 0) +
|
||||
selectedFilters.checked.length
|
||||
}
|
||||
handleToggleFilters={() => toggleFilters(!showFilters)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
return children({ results: value.results });
|
||||
};
|
||||
|
||||
export { SearchResultComponent as SearchResult };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EmptyState, Progress } from '@backstage/core';
|
||||
import { SearchResult } from '@backstage/search-common';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
|
||||
import { useSearch } from '../SearchContext';
|
||||
|
||||
type Props = {
|
||||
children: (results: { results: SearchResult[] }) => JSX.Element;
|
||||
};
|
||||
|
||||
export const SearchResultNext = ({ children }: Props) => {
|
||||
const {
|
||||
result: { loading, error, value },
|
||||
} = useSearch();
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error encountered while fetching search results. {error.toString()}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return <EmptyState missing="data" title="Sorry, no results were found" />;
|
||||
}
|
||||
|
||||
return children({ results: value.results });
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { SearchResultNext } from './SearchResultNext';
|
||||
@@ -15,13 +15,10 @@
|
||||
*/
|
||||
|
||||
export * from './Filters';
|
||||
export * from './SearchFilterNext';
|
||||
export * from './SearchFilter';
|
||||
export * from './SearchBar';
|
||||
export * from './SearchBarNext';
|
||||
export * from './SearchPage';
|
||||
export * from './SearchPageNext';
|
||||
export * from './SearchResult';
|
||||
export * from './SearchResultNext';
|
||||
export * from './DefaultResultListItem';
|
||||
export * from './SidebarSearch';
|
||||
export * from './SearchContext';
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// TODO: export searchApiRef from ./apis once interface is stable and settled.
|
||||
export { searchApiRef } from './apis';
|
||||
export {
|
||||
searchPlugin,
|
||||
searchPlugin as plugin,
|
||||
SearchPage,
|
||||
SearchPageNext,
|
||||
SearchBarNext,
|
||||
SearchResultNext,
|
||||
SearchResult,
|
||||
DefaultResultListItem,
|
||||
} from './plugin';
|
||||
export {
|
||||
@@ -31,8 +31,8 @@ export {
|
||||
SearchContextProvider,
|
||||
useSearch,
|
||||
SearchPage as Router,
|
||||
SearchFilter,
|
||||
SearchFilterNext,
|
||||
SearchResult,
|
||||
SidebarSearch,
|
||||
} from './components';
|
||||
export type { FiltersState } from './components';
|
||||
|
||||
@@ -22,9 +22,6 @@ import {
|
||||
createComponentExtension,
|
||||
} from '@backstage/core';
|
||||
import { SearchClient, searchApiRef } from './apis';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { SearchPage as SearchPageComponent } from './components/SearchPage';
|
||||
import { SearchPageNext as SearchPageNextComponent } from './components/SearchPageNext';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '/search',
|
||||
@@ -41,16 +38,12 @@ export const searchPlugin = createPlugin({
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: searchApiRef,
|
||||
deps: { catalogApi: catalogApiRef, discoveryApi: discoveryApiRef },
|
||||
factory: ({ catalogApi, discoveryApi }) => {
|
||||
return new SearchClient({ catalogApi, discoveryApi });
|
||||
deps: { discoveryApi: discoveryApiRef },
|
||||
factory: ({ discoveryApi }) => {
|
||||
return new SearchClient({ discoveryApi });
|
||||
},
|
||||
}),
|
||||
],
|
||||
register({ router }) {
|
||||
router.addRoute(rootRouteRef, SearchPageComponent);
|
||||
router.addRoute(rootNextRouteRef, SearchPageNextComponent);
|
||||
},
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
nextRoot: rootNextRouteRef,
|
||||
@@ -64,28 +57,59 @@ export const SearchPage = searchPlugin.provide(
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated This component was used for rapid prototyping of the Backstage
|
||||
* Search platform. Now that the API has stabilized, you should use the
|
||||
* <SearchPage /> component instead. This component will be removed in an
|
||||
* upcoming release.
|
||||
*/
|
||||
export const SearchPageNext = searchPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
import('./components/SearchPageNext').then(m => m.SearchPageNext),
|
||||
component: () => import('./components/SearchPage').then(m => m.SearchPage),
|
||||
mountPoint: rootNextRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
export const SearchBarNext = searchPlugin.provide(
|
||||
export const SearchBar = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/SearchBarNext').then(m => m.SearchBarNext),
|
||||
lazy: () => import('./components/SearchBar').then(m => m.SearchBar),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated This component was used for rapid prototyping of the Backstage
|
||||
* Search platform. Now that the API has stabilized, you should use the
|
||||
* <SearchBar /> component instead. This component will be removed in an
|
||||
* upcoming release.
|
||||
*/
|
||||
export const SearchBarNext = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () => import('./components/SearchBar').then(m => m.SearchBar),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const SearchResult = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () => import('./components/SearchResult').then(m => m.SearchResult),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated This component was used for rapid prototyping of the Backstage
|
||||
* Search platform. Now that the API has stabilized, you should use the
|
||||
* <SearchResult /> component instead. This component will be removed in an
|
||||
* upcoming release.
|
||||
*/
|
||||
export const SearchResultNext = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/SearchResultNext').then(m => m.SearchResultNext),
|
||||
lazy: () => import('./components/SearchResult').then(m => m.SearchResult),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user