Provide backwards compatibility instead of an upgrade message.

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2021-06-08 22:03:08 +02:00
parent 3bc581732c
commit d15b1434e6
11 changed files with 680 additions and 39 deletions
+2
View File
@@ -30,6 +30,8 @@
},
"dependencies": {
"@backstage/core": "^0.7.11",
"@backstage/catalog-model": "^0.8.0",
"@backstage/plugin-catalog-react": "^0.2.0",
"@backstage/search-common": "^0.1.1",
"@backstage/config": "^0.1.5",
"@backstage/theme": "^0.2.8",
-11
View File
@@ -23,17 +23,6 @@ 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 {
query(query: SearchQuery): Promise<SearchResultSet>;
}
@@ -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>
);
};
@@ -0,0 +1,19 @@
/*
* 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.
*/
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>
</>
);
};
@@ -0,0 +1,17 @@
/*
* 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 { LegacySearchPage } from './LegacySearchPage';
@@ -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;
@@ -85,11 +90,11 @@ describe('SearchPage', () => {
expect(getByText('Route Children')).toBeInTheDocument();
});
it('renders upgrade error whe no router children are provided', async () => {
it('renders legacy search when no router children are provided', async () => {
(useOutlet as jest.Mock).mockReturnValueOnce(null);
const { getByText } = await renderInTestApp(<SearchPage />);
expect(getByText('Error: No Search Layout Found')).toBeInTheDocument();
expect(getByText('LegacySearchPageMock')).toBeInTheDocument();
});
it('replaces window history with expected query parameters', async () => {
@@ -19,31 +19,7 @@ import qs from 'qs';
import { useLocation, useOutlet } from 'react-router';
import { SearchContextProvider, useSearch } from '../SearchContext';
import { JsonObject } from '@backstage/config';
import { Content, Header, Page, Link, WarningPanel } from '@backstage/core';
const UpdateInstructions = () => {
return (
<Page themeId="home">
<Header title="Search" />
<Content>
<WarningPanel
severity="error"
title="No Search Layout Found"
message={
<>
As of v0.4.0 of the Backstage Search Plugin, a search layout must
be provided by the App. For detailed instructions, check the{' '}
<Link to="https://backstage.io/docs/features/search/getting-started">
getting started guide
</Link>
.
</>
}
/>
</Content>
</Page>
);
};
import { LegacySearchPage } from '../LegacySearchPage';
export const UrlUpdater = () => {
const { term, types, pageCursor, filters } = useSearch();
@@ -87,7 +63,7 @@ export const SearchPage = () => {
return (
<SearchContextProvider initialState={initialState}>
<UrlUpdater />
{outlet || <UpdateInstructions />}
{outlet || <LegacySearchPage />}
</SearchContextProvider>
);
};