diff --git a/workspaces/global-header/plugins/global-header/package.json b/workspaces/global-header/plugins/global-header/package.json index 9ab5d0648..383c1e4c2 100644 --- a/workspaces/global-header/plugins/global-header/package.json +++ b/workspaces/global-header/plugins/global-header/package.json @@ -27,6 +27,7 @@ "start": "backstage-cli package start", "build": "backstage-cli package build", "lint": "backstage-cli package lint", + "lint:fix": "backstage-cli package lint --fix", "test": "backstage-cli package test", "clean": "backstage-cli package clean", "prepack": "backstage-cli package prepack", @@ -40,6 +41,7 @@ "@backstage/plugin-search-backend-module-catalog": "^0.2.6", "@backstage/plugin-search-backend-module-pg": "^0.5.39", "@backstage/plugin-search-backend-module-techdocs": "^0.3.4", + "@backstage/plugin-search-common": "^1.2.16", "@backstage/plugin-search-react": "1.8.4", "@backstage/theme": "^0.6.2", "@mui/icons-material": "5.16.13", @@ -57,6 +59,7 @@ "@backstage/cli": "^0.29.0", "@backstage/core-app-api": "^1.15.2", "@backstage/dev-utils": "^1.1.4", + "@backstage/plugin-search-common": "^1.2.16", "@backstage/test-utils": "^1.7.2", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.test.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.test.tsx new file mode 100644 index 000000000..9b3838681 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { SearchBar } from './SearchBar'; +import { + searchApiRef, + SearchContextProvider, +} from '@backstage/plugin-search-react'; +import { mockApis, TestApiProvider } from '@backstage/test-utils'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { configApiRef } from '@backstage/core-plugin-api'; + +const createInitialState = ({ + term = '', + filters = {}, + types = ['*'], + pageCursor = '', +} = {}) => ({ + term, + filters, + types, + pageCursor, +}); + +const mockSearchApi = { + query: jest.fn().mockResolvedValue({ + results: [ + { + type: 'software-catalog', + document: { + title: 'Example Result', + location: '/catalog/default/component/example', + }, + }, + ], + }), +}; + +const mockConfig = mockApis.config({ + data: { + app: { + url: 'https://example.com', + }, + }, +}); + +jest.mock('../../utils/stringUtils', () => ({ + ...jest.requireActual('../../utils/stringUtils'), + highlightMatch: jest.fn(text => text), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); + +describe('SearchBar', () => { + const setup = (term = '') => { + const setSearchTerm = jest.fn(); + const navigate = jest.fn(); + jest.mocked(useNavigate).mockReturnValue(navigate); + + render( + + + + + + + , + ); + return { setSearchTerm, navigate }; + }; + + it('renders the search input', () => { + setup(); + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); + }); + + it('calls setSearchTerm on input change', () => { + const { setSearchTerm } = setup(); + fireEvent.change(screen.getByPlaceholderText('Search...'), { + target: { value: 'test' }, + }); + expect(setSearchTerm).toHaveBeenCalledWith('test'); + }); + + it('displays "No results found" when there are no results', async () => { + mockSearchApi.query.mockResolvedValueOnce({ results: [] }); + setup('test'); + fireEvent.change(screen.getByPlaceholderText('Search...'), { + target: { value: 'test' }, + }); + await waitFor(() => { + expect(screen.getByText('No results found')).toBeInTheDocument(); + }); + }); + + it('displays search results', async () => { + setup('example'); + fireEvent.change(screen.getByPlaceholderText('Search...'), { + target: { value: 'example' }, + }); + await waitFor(() => { + expect(screen.getByText('Example Result')).toBeInTheDocument(); + }); + }); + + it('navigates to the search page on Enter key press', async () => { + const { navigate } = setup('example'); + fireEvent.change(screen.getByPlaceholderText('Search...'), { + target: { value: 'example' }, + }); + await waitFor(() => { + expect(screen.getByText('Example Result')).toBeInTheDocument(); + }); + + fireEvent.keyDown(screen.getByPlaceholderText('Search...'), { + key: 'Enter', + code: 'Enter', + }); + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith('/search?query=example'); + }); + }); +}); diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.tsx index c635ee3e4..243c18565 100644 --- a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * Copyright Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,19 +19,11 @@ import { SearchResultState, SearchResultProps, } from '@backstage/plugin-search-react'; -import Typography from '@mui/material/Typography'; -import { Link } from '@backstage/core-components'; -import ListItem from '@mui/material/ListItem'; -import Box from '@mui/material/Box'; -import Divider from '@mui/material/Divider'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import Autocomplete from '@mui/material/Autocomplete'; -import TextField from '@mui/material/TextField'; -import InputAdornment from '@mui/material/InputAdornment'; -import SearchIcon from '@mui/icons-material/Search'; -import { createSearchLink, highlightMatch } from '../../utils/stringUtils'; -import styles from './SearchBar.module.css'; +import { createSearchLink } from '../../utils/stringUtils'; import { useNavigate } from 'react-router-dom'; +import { SearchInput } from './SearchInput'; +import { SearchOption } from './SearchOption'; interface SearchBarProps { query: SearchResultProps['query']; @@ -75,72 +67,23 @@ export const SearchBar = (props: SearchBarProps) => { } }} renderInput={params => ( - - - - ), - }} - sx={{ - pt: '6px', - input: { color: '#fff' }, - button: { color: '#fff' }, - }} /> )} - renderOption={(renderProps, option, { index }) => { - if (option === query?.term && index === options.length - 1) { - return ( - - - - - - - All results - - - - - - - ); - } - - const result = results.find(r => r.document.title === option); - return ( - - - - - {option === 'No results found' - ? option - : highlightMatch(option, query?.term ?? '')} - - - - - ); - }} + renderOption={(renderProps, option, { index }) => ( + + )} ListboxProps={{ style: { maxHeight: 600 }, }} diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchComponent.test.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchComponent.test.tsx new file mode 100644 index 000000000..1fcfc7a6f --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchComponent.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { render, screen, fireEvent } from '@testing-library/react'; +import { SearchComponent } from './SearchComponent'; +import { + searchApiRef, + SearchContextProvider, +} from '@backstage/plugin-search-react'; +import { mockApis, TestApiProvider } from '@backstage/test-utils'; +import { configApiRef } from '@backstage/core-plugin-api'; + +jest.mock('./SearchBar', () => ({ + SearchBar: ({ + query, + setSearchTerm, + }: { + query: { term: string }; + setSearchTerm: (term: string) => void; + }) => ( + setSearchTerm(e.target.value)} + /> + ), +})); + +const mockSearchApi = { + query: jest.fn().mockResolvedValue({ + results: [ + { + type: 'software-catalog', + document: { + title: 'Example Result', + location: '/catalog/default/component/example', + }, + }, + ], + }), +}; + +const mockConfig = mockApis.config({ + data: { app: { baseUrl: 'https://example.com' } }, +}); + +describe('SearchComponent', () => { + it('should render the SearchBar with initial state', () => { + render( + + + + + , + ); + + const searchBar = screen.getByTestId('search-bar'); + expect(searchBar).toBeInTheDocument(); + expect(searchBar).toHaveValue(''); + }); + + it('should update the search term when typing in the SearchBar', () => { + render( + + + + + , + ); + + const searchBar = screen.getByTestId('search-bar'); + fireEvent.change(searchBar, { target: { value: 'example' } }); + + expect(searchBar).toHaveValue('example'); + }); +}); diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchInput.test.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchInput.test.tsx new file mode 100644 index 000000000..0fce4d101 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchInput.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { render, screen } from '@testing-library/react'; +import { SearchInput } from './SearchInput'; +import InputAdornment from '@mui/material/InputAdornment'; +import SearchIcon from '@mui/icons-material/Search'; + +describe('SearchInput', () => { + const params = { + InputProps: { + startAdornment: ( + + + + ), + }, + }; + + it('renders search input with placeholder', () => { + render(); + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); + }); + + it('displays error state', () => { + render( + , + ); + expect(screen.getByText('Error fetching results')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('renders input adornment', () => { + render(); + expect(screen.getByTestId('SearchIcon')).toBeInTheDocument(); + }); +}); diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchInput.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchInput.tsx new file mode 100644 index 000000000..0ed2b4fe4 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchInput.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import SearchIcon from '@mui/icons-material/Search'; + +interface SearchInputProps { + params: any; + error: boolean; + helperText: string; +} + +export const SearchInput = ({ + params, + error, + helperText, +}: SearchInputProps) => ( + + + + ), + }} + sx={{ + pt: '6px', + input: { color: '#fff' }, + button: { color: '#fff' }, + }} + /> +); diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchOption.test.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchOption.test.tsx new file mode 100644 index 000000000..6fea6c384 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchOption.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { render, screen } from '@testing-library/react'; +import { SearchOption } from './SearchOption'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { Result, SearchDocument } from '@backstage/plugin-search-common'; + +jest.mock('./SearchResultItem', () => ({ + SearchResultItem: jest.fn(({ option }) =>
{option}
), +})); + +describe('SearchOption', () => { + const renderProps = {}; + const searchLink = '/search-link'; + const query = { term: 'test' }; + const results = [ + { + document: { title: 'Result 1', location: '/result-1' }, + } as Result, + ]; + + it('renders "All results" option', () => { + render( + + + , + ); + + expect(screen.getByText('All results')).toBeInTheDocument(); + }); + + it('renders search result item', () => { + render( + + + , + ); + + expect(screen.getByText('Result 1')).toBeInTheDocument(); + }); + + it('renders "No results found" option', () => { + render( + + + , + ); + + expect(screen.getByText('No results found')).toBeInTheDocument(); + }); +}); diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchOption.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchOption.tsx new file mode 100644 index 000000000..78e6205f3 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchOption.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import { Link } from '@backstage/core-components'; +import ListItem from '@mui/material/ListItem'; +import Typography from '@mui/material/Typography'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import { SearchResultItem } from './SearchResultItem'; +import { Result, SearchDocument } from '@backstage/plugin-search-common'; +import { SearchResultProps } from '@backstage/plugin-search-react'; + +interface SearchOptionProps { + option: string; + index: number; + options: string[]; + query: SearchResultProps['query']; + results: Result[]; + renderProps: any; + searchLink: string; +} + +export const SearchOption = ({ + option, + index, + options, + query, + results, + renderProps, + searchLink, +}: SearchOptionProps) => { + if (option === query?.term && index === options.length - 1) { + return ( + + + + + + All results + + + + + + ); + } + + const result = results.find(r => r.document.title === option); + return ( + + ); +}; diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchResultItem.test.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchResultItem.test.tsx new file mode 100644 index 000000000..41b4ea10a --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchResultItem.test.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { render, screen } from '@testing-library/react'; +import { SearchResultItem } from './SearchResultItem'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { Result, SearchDocument } from '@backstage/plugin-search-common'; + +jest.mock('../../utils/stringUtils', () => ({ + highlightMatch: jest.fn((text, _) => text), +})); + +describe('SearchResultItem', () => { + const renderProps = {}; + const query = { term: 'test' }; + + it('renders "No results found" option', () => { + render( + + + , + ); + + expect(screen.getByText('No results found')).toBeInTheDocument(); + }); + + it('renders search result item with highlighted match', () => { + const result = { + document: { title: 'Result 1', location: '/result-1' }, + } as Result; + render( + + + , + ); + + expect(screen.getByText('Result 1')).toBeInTheDocument(); + }); + + it('links to the correct location', () => { + const result = { + document: { title: 'Result 1', location: '/result-1' }, + } as Result; + render( + + + , + ); + + expect(screen.getByRole('link')).toHaveAttribute('href', '/result-1'); + }); + + it('links to "#" when location is undefined', () => { + render( + + + , + ); + + expect(screen.getByRole('link')).toHaveAttribute('href', '/'); + }); +}); diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchResultItem.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchResultItem.tsx new file mode 100644 index 000000000..9543b0075 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchResultItem.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { Link } from '@backstage/core-components'; +import ListItem from '@mui/material/ListItem'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { highlightMatch } from '../../utils/stringUtils'; +import { SearchResultProps } from '@backstage/plugin-search-react'; +import { Result, SearchDocument } from '@backstage/plugin-search-common'; + +interface SearchResultItemProps { + option: string; + query: SearchResultProps['query']; + result: Result | undefined; + renderProps: any; +} + +export const SearchResultItem = ({ + option, + query, + result, + renderProps, +}: SearchResultItemProps) => ( + + + + + {option === 'No results found' + ? option + : highlightMatch(option, query?.term ?? '')} + + + + +); diff --git a/workspaces/global-header/plugins/global-header/src/utils/stringUtils.test.tsx b/workspaces/global-header/plugins/global-header/src/utils/stringUtils.test.tsx index ecc1db469..d4a4b0482 100644 --- a/workspaces/global-header/plugins/global-header/src/utils/stringUtils.test.tsx +++ b/workspaces/global-header/plugins/global-header/src/utils/stringUtils.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * Copyright Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx b/workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx index 218fcebad..982466645 100644 --- a/workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx +++ b/workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * Copyright Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/workspaces/global-header/yarn.lock b/workspaces/global-header/yarn.lock index 706168763..c389c6ae1 100644 --- a/workspaces/global-header/yarn.lock +++ b/workspaces/global-header/yarn.lock @@ -9985,6 +9985,7 @@ __metadata: "@backstage/plugin-search-backend-module-catalog": ^0.2.6 "@backstage/plugin-search-backend-module-pg": ^0.5.39 "@backstage/plugin-search-backend-module-techdocs": ^0.3.4 + "@backstage/plugin-search-common": ^1.2.16 "@backstage/plugin-search-react": 1.8.4 "@backstage/test-utils": ^1.7.2 "@backstage/theme": ^0.6.2