diff --git a/plugins/search/package.json b/plugins/search/package.json index e434c6d830..a74091e497 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -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", diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index 4f0ab4a7f7..5e039cbb84 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -23,17 +23,6 @@ export const searchApiRef = createApiRef({ 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; - export interface SearchApi { query(query: SearchQuery): Promise; } diff --git a/plugins/search/src/components/LegacySearchPage/Filters/Filters.tsx b/plugins/search/src/components/LegacySearchPage/Filters/Filters.tsx new file mode 100644 index 0000000000..888a583547 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/Filters/Filters.tsx @@ -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; +}; + +export type FilterOptions = { + kind: Array; + lifecycle: Array; +}; + +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 ( + + Filters} + action={ + + } + /> + + {filterOptions.kind.length === 0 && filterOptions.lifecycle.length === 0 && ( + + + Filters cannot be applied to available results + + + )} + {filterOptions.kind.length > 0 && ( + + Kind + + + )} + {filterOptions.lifecycle.length > 0 && ( + + Lifecycle + + {filterOptions.lifecycle.map(filter => ( + updateChecked(filter)} + > + + + + ))} + + + )} + + ); +}; diff --git a/plugins/search/src/components/LegacySearchPage/Filters/FiltersButton.tsx b/plugins/search/src/components/LegacySearchPage/Filters/FiltersButton.tsx new file mode 100644 index 0000000000..4775e9d8b8 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/Filters/FiltersButton.tsx @@ -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 ( +
+ + + + + Filters ({numberOfSelectedFilters ? numberOfSelectedFilters : 0}) + +
+ ); +}; diff --git a/plugins/search/src/components/LegacySearchPage/Filters/index.ts b/plugins/search/src/components/LegacySearchPage/Filters/index.ts new file mode 100644 index 0000000000..ea431a3c01 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/Filters/index.ts @@ -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'; diff --git a/plugins/search/src/components/LegacySearchPage/LegacySearchBar.tsx b/plugins/search/src/components/LegacySearchPage/LegacySearchBar.tsx new file mode 100644 index 0000000000..9aa48e7284 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/LegacySearchBar.tsx @@ -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 ( + handleSearch(e)} + className={classes.root} + > + + + + handleSearch(e)} + inputProps={{ 'aria-label': 'search backstage' }} + /> + handleClearSearchBar()}> + + + + ); +}; diff --git a/plugins/search/src/components/LegacySearchPage/LegacySearchPage.tsx b/plugins/search/src/components/LegacySearchPage/LegacySearchPage.tsx new file mode 100644 index 0000000000..7e9d7b205c --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/LegacySearchPage.tsx @@ -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('query'); + const [searchQuery, setSearchQuery] = useState(queryString ?? ''); + + const handleSearch = (event: React.ChangeEvent) => { + event.preventDefault(); + setSearchQuery(event.target.value); + }; + + useEffect(() => setSearchQuery(queryString ?? ''), [queryString]); + + useDebounce( + () => { + setQueryString(searchQuery); + }, + 200, + [searchQuery], + ); + + const handleClearSearchBar = () => { + setSearchQuery(''); + }; + + return ( + +
+ + + + + + + + + + + + ); +}; diff --git a/plugins/search/src/components/LegacySearchPage/LegacySearchResult.tsx b/plugins/search/src/components/LegacySearchPage/LegacySearchResult.tsx new file mode 100644 index 0000000000..b89a05e276 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/LegacySearchResult.tsx @@ -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; + +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.name} + ), + }, + { + 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 ( +
+ + + + {searchQuery ? ( + + {`${numberOfResults} `} + {numberOfResults > 1 ? `results for ` : `result for `} + "{searchQuery}"{' '} + + ) : ( + {`${numberOfResults} results`} + )} + +
+ ); +}; + +export const SearchResult = ({ searchQuery }: SearchResultProps) => { + const catalogApi = useApi(catalogApiRef); + + const [showFilters, toggleFilters] = useState(false); + const [selectedFilters, setSelectedFilters] = useState({ + selected: '', + checked: [], + }); + + const [filteredResults, setFilteredResults] = useState([]); + + 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 ; + } + if (error) { + return ( + + Error encountered while fetching search results. {error.toString()} + + ); + } + if (!results || results.length === 0) { + return ; + } + + 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, + lifecycle: [] as Array, + }, + ); + + return ( + <> + + {showFilters && ( + + + + )} + + toggleFilters(!showFilters)} + /> + } + /> + + + + ); +}; diff --git a/plugins/search/src/components/LegacySearchPage/index.ts b/plugins/search/src/components/LegacySearchPage/index.ts new file mode 100644 index 0000000000..b28b8ba9e5 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/index.ts @@ -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'; diff --git a/plugins/search/src/components/SearchPage/SearchPage.test.tsx b/plugins/search/src/components/SearchPage/SearchPage.test.tsx index 20bfb707cf..4b65466866 100644 --- a/plugins/search/src/components/SearchPage/SearchPage.test.tsx +++ b/plugins/search/src/components/SearchPage/SearchPage.test.tsx @@ -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(); - expect(getByText('Error: No Search Layout Found')).toBeInTheDocument(); + expect(getByText('LegacySearchPageMock')).toBeInTheDocument(); }); it('replaces window history with expected query parameters', async () => { diff --git a/plugins/search/src/components/SearchPage/SearchPage.tsx b/plugins/search/src/components/SearchPage/SearchPage.tsx index 5eb84c689c..25687c72ce 100644 --- a/plugins/search/src/components/SearchPage/SearchPage.tsx +++ b/plugins/search/src/components/SearchPage/SearchPage.tsx @@ -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 ( - -
- - - As of v0.4.0 of the Backstage Search Plugin, a search layout must - be provided by the App. For detailed instructions, check the{' '} - - getting started guide - - . - - } - /> - - - ); -}; +import { LegacySearchPage } from '../LegacySearchPage'; export const UrlUpdater = () => { const { term, types, pageCursor, filters } = useSearch(); @@ -87,7 +63,7 @@ export const SearchPage = () => { return ( - {outlet || } + {outlet || } ); };