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

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