From 754bf46bc0e7c2de709fab07a774439ca9ddd20e Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Mon, 6 Jan 2025 22:59:44 -0500 Subject: [PATCH 1/3] feat(global-header): implemented searchBar Signed-off-by: Yi Cai --- .../packages/app/src/components/Root/Root.tsx | 2 +- .../plugins/global-header/package.json | 6 + .../CreateDropdown.tsx | 33 ++-- .../HeaderDropdownComponent.tsx | 2 +- .../SearchComponent/SearchBar.module.css | 12 ++ .../components/SearchComponent/SearchBar.tsx | 151 ++++++++++++++++++ .../SearchComponent/SearchComponent.tsx | 41 +++-- .../global-header/src/utils/stringUtils.tsx | 55 +++++++ workspaces/global-header/yarn.lock | 10 +- 9 files changed, 269 insertions(+), 43 deletions(-) create mode 100644 workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.module.css create mode 100644 workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.tsx create mode 100644 workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx diff --git a/workspaces/global-header/packages/app/src/components/Root/Root.tsx b/workspaces/global-header/packages/app/src/components/Root/Root.tsx index d5e064e11..3b963d5ff 100644 --- a/workspaces/global-header/packages/app/src/components/Root/Root.tsx +++ b/workspaces/global-header/packages/app/src/components/Root/Root.tsx @@ -66,7 +66,7 @@ const SidebarLogo = () => { ); }; -export const Root = ({ children }: PropsWithChildren<{}>) => ( +export const Root = ({ children = null }: PropsWithChildren<{}>) => ( diff --git a/workspaces/global-header/plugins/global-header/package.json b/workspaces/global-header/plugins/global-header/package.json index d090f6747..9ab5d0648 100644 --- a/workspaces/global-header/plugins/global-header/package.json +++ b/workspaces/global-header/plugins/global-header/package.json @@ -35,6 +35,12 @@ "dependencies": { "@backstage/core-components": "^0.16.1", "@backstage/core-plugin-api": "^1.10.1", + "@backstage/plugin-search": "1.4.21", + "@backstage/plugin-search-backend": "^1.8.0", + "@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-react": "1.8.4", "@backstage/theme": "^0.6.2", "@mui/icons-material": "5.16.13", "@mui/material": "5.16.13", diff --git a/workspaces/global-header/plugins/global-header/src/components/HeaderDropdownComponent/CreateDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/HeaderDropdownComponent/CreateDropdown.tsx index 690dd5239..ae45d7397 100644 --- a/workspaces/global-header/plugins/global-header/src/components/HeaderDropdownComponent/CreateDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/HeaderDropdownComponent/CreateDropdown.tsx @@ -54,21 +54,6 @@ const models = [ }, ]; -const menuSections = [ - { - sectionKey: 'templates', - sectionLabel: 'Use a template', - optionalLinkLabel: 'All templates', - optionalLink: '/create', - items: models.map(m => ({ - itemKey: m.key, - label: m.label, - link: `/create/templates/default/${m.value}`, - })), - handleClose: () => {}, - }, -]; - const menuBottomItems: MenuItemConfig[] = [ { itemKey: 'custom', @@ -83,11 +68,25 @@ const CreateDropdown: React.FC = ({ anchorEl, setAnchorEl, }) => { + const menuSections = [ + { + sectionKey: 'templates', + sectionLabel: 'Use a template', + optionalLinkLabel: 'All templates', + optionalLink: '/create', + items: models.map(m => ({ + itemKey: m.key, + label: m.label, + link: `/create/templates/default/${m.value}`, + })), + handleClose: () => setAnchorEl(null), + }, + ]; return ( - Create + Create } menuSections={menuSections} @@ -95,7 +94,7 @@ const CreateDropdown: React.FC = ({ buttonProps={{ color: 'primary', variant: 'contained', - sx: { marginRight: '1rem' }, + sx: { mr: 2 }, }} buttonClick={handleMenu} anchorEl={anchorEl} diff --git a/workspaces/global-header/plugins/global-header/src/components/HeaderDropdownComponent/HeaderDropdownComponent.tsx b/workspaces/global-header/plugins/global-header/src/components/HeaderDropdownComponent/HeaderDropdownComponent.tsx index b29c43cef..b35c1fd22 100644 --- a/workspaces/global-header/plugins/global-header/src/components/HeaderDropdownComponent/HeaderDropdownComponent.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/HeaderDropdownComponent/HeaderDropdownComponent.tsx @@ -115,7 +115,7 @@ const HeaderDropdownComponent: React.FC = ({ setAnchorEl(null)} + handleClose={section.handleClose} /> ))} {menuBottomItems.map( diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.module.css b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.module.css new file mode 100644 index 000000000..597283764 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.module.css @@ -0,0 +1,12 @@ +/* SearchBar.module.css */ +.allResultsOption { + background-color: transparent !important; +} + +.allResultsOption:hover { + background-color: transparent !important; +} + +.allResultsOption:focus { + background-color: transparent !important; +} 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 new file mode 100644 index 000000000..bdfcb6e3a --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.tsx @@ -0,0 +1,151 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { + 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 { highlightMatch } from '../../utils/stringUtils'; +import styles from './SearchBar.module.css'; +import { useNavigate } from 'react-router-dom'; + +interface SearchBarProps { + query: SearchResultProps['query']; + setSearchTerm: (term: string) => void; +} +export const SearchBar = (props: SearchBarProps) => { + const { query, setSearchTerm } = props; + const navigate = useNavigate(); + + return ( + + {({ loading, error, value }) => { + const results = query?.term ? value?.results ?? [] : []; + let options: string[] = []; + if (results.length > 0) { + options = [ + ...results.slice(0, 5).map(result => result.document.title), + `${query?.term}`, + ]; + } else if (query?.term) { + options = ['No results found']; + } + + return ( + option ?? ''} + onInputChange={(_, inputValue) => setSearchTerm(inputValue)} + sx={{ width: '100%' }} + filterOptions={x => x} + getOptionDisabled={option => option === 'No results found'} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault(); + if (query?.term) { + navigate(`/search?query=${query?.term}`); + } + } + }} + renderInput={params => ( + + + + ), + }} + sx={{ + pt: '0.5rem', + 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 ?? '')} + + + + + ); + }} + ListboxProps={{ + style: { maxHeight: 600 }, + }} + /> + ); + }} + + ); +}; diff --git a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchComponent.tsx b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchComponent.tsx index 5f0b6584c..97d581f33 100644 --- a/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchComponent.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchComponent.tsx @@ -14,32 +14,29 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import Box from '@mui/material/Box'; -import SearchIcon from '@mui/icons-material/Search'; -import InputBase from '@mui/material/InputBase'; +import { SearchBar } from './SearchBar'; +import { SearchContextProvider } from '@backstage/plugin-search-react'; export const SearchComponent = () => { + const [searchTerm, setSearchTerm] = useState(''); + return ( - - - + + + - - + ); }; diff --git a/workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx b/workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx new file mode 100644 index 000000000..0aa6e864e --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 Typography from '@mui/material/Typography'; + +/** + * Highlights the substring that matches the query term. + * @param text The full text to render. + * @param query The query term to highlight. + * @returns JSX.Element with highlighted matching substring. + */ +export const highlightMatch = (text: string, query: string) => { + if (!query) return <>{text}; + + const regex = new RegExp(`(${query})`, 'i'); + const parts = text.split(regex); + + return ( + <> + {parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : ( + + {part} + + ), + )} + + ); +}; diff --git a/workspaces/global-header/yarn.lock b/workspaces/global-header/yarn.lock index d21fea958..706168763 100644 --- a/workspaces/global-header/yarn.lock +++ b/workspaces/global-header/yarn.lock @@ -5046,7 +5046,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-search-react@npm:^1.8.4": +"@backstage/plugin-search-react@npm:1.8.4, @backstage/plugin-search-react@npm:^1.8.4": version: 1.8.4 resolution: "@backstage/plugin-search-react@npm:1.8.4" dependencies: @@ -5076,7 +5076,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-search@npm:^1.4.21": +"@backstage/plugin-search@npm:1.4.21, @backstage/plugin-search@npm:^1.4.21": version: 1.4.21 resolution: "@backstage/plugin-search@npm:1.4.21" dependencies: @@ -9980,6 +9980,12 @@ __metadata: "@backstage/core-components": ^0.16.1 "@backstage/core-plugin-api": ^1.10.1 "@backstage/dev-utils": ^1.1.4 + "@backstage/plugin-search": 1.4.21 + "@backstage/plugin-search-backend": ^1.8.0 + "@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-react": 1.8.4 "@backstage/test-utils": ^1.7.2 "@backstage/theme": ^0.6.2 "@mui/icons-material": 5.16.13 From 5bc46197e16d2f0b3633de930929cfa9c44b7054 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Mon, 6 Jan 2025 23:39:57 -0500 Subject: [PATCH 2/3] fixed failed unit test Signed-off-by: Yi Cai --- .../ExampleComponent.test.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/workspaces/global-header/plugins/global-header/src/components/ExampleComponent/ExampleComponent.test.tsx b/workspaces/global-header/plugins/global-header/src/components/ExampleComponent/ExampleComponent.test.tsx index a27d2f7fe..24dfc6733 100644 --- a/workspaces/global-header/plugins/global-header/src/components/ExampleComponent/ExampleComponent.test.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/ExampleComponent/ExampleComponent.test.tsx @@ -19,13 +19,33 @@ import { ExampleComponent } from './ExampleComponent'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { screen } from '@testing-library/react'; -import { registerMswTestHooks, renderInTestApp } from '@backstage/test-utils'; +import { + registerMswTestHooks, + renderInTestApp, + TestApiProvider, +} from '@backstage/test-utils'; +import { searchApiRef } from '@backstage/plugin-search-react'; describe('ExampleComponent', () => { const server = setupServer(); // Enable sane handlers for network requests registerMswTestHooks(server); + // Mock the search API response + const mockSearchApi = { + query: jest.fn().mockResolvedValue({ + results: [ + { + type: 'software-catalog', + document: { + title: 'Example Result', + location: '/catalog/default/component/example', + }, + }, + ], + }), + }; + // setup mock response beforeEach(() => { server.use( @@ -34,7 +54,11 @@ describe('ExampleComponent', () => { }); it('should render', async () => { - await renderInTestApp(); + await renderInTestApp( + + + , + ); expect(screen.getByText('Global Header Example')).toBeInTheDocument(); }); }); From f910b22ef2cd408624f15a87fa788518db3accc2 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 7 Jan 2025 11:22:12 -0500 Subject: [PATCH 3/3] Addressed review comments Signed-off-by: Yi Cai --- .../components/SearchComponent/SearchBar.tsx | 9 +-- .../src/utils/stringUtils.test.tsx | 63 +++++++++++++++++++ .../global-header/src/utils/stringUtils.tsx | 10 ++- 3 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 workspaces/global-header/plugins/global-header/src/utils/stringUtils.test.tsx 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 bdfcb6e3a..c635ee3e4 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 @@ -29,7 +29,7 @@ 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 { highlightMatch } from '../../utils/stringUtils'; +import { createSearchLink, highlightMatch } from '../../utils/stringUtils'; import styles from './SearchBar.module.css'; import { useNavigate } from 'react-router-dom'; @@ -54,6 +54,7 @@ export const SearchBar = (props: SearchBarProps) => { } else if (query?.term) { options = ['No results found']; } + const searchLink = createSearchLink(query?.term ?? ''); return ( { if (event.key === 'Enter') { event.preventDefault(); if (query?.term) { - navigate(`/search?query=${query?.term}`); + navigate(searchLink); } } }} @@ -90,7 +91,7 @@ export const SearchBar = (props: SearchBarProps) => { ), }} sx={{ - pt: '0.5rem', + pt: '6px', input: { color: '#fff' }, button: { color: '#fff' }, }} @@ -101,7 +102,7 @@ export const SearchBar = (props: SearchBarProps) => { return ( - + { + it('should highlight the query within the text', () => { + const text = 'This is a test string'; + const query = 'test'; + const { container } = render(<>{highlightMatch(text, query)}); + + const highlighted = screen.getByText('test'); + expect(highlighted).toHaveStyle('font-weight: normal'); + + const spans = container.querySelectorAll('span'); + expect(spans[0].textContent?.trim()).toBe('This is a'); + expect(spans[0]).toHaveStyle('font-weight: 700'); + expect(spans[2].textContent?.trim()).toBe('string'); + expect(spans[2]).toHaveStyle('font-weight: 700'); + }); + + it('should highlight the query case-insensitively', () => { + const text = 'This is a Test string'; + const query = 'test'; + render(<>{highlightMatch(text, query)}); + + const highlighted = screen.getByText('Test'); + expect(highlighted).toHaveStyle('font-weight: normal'); + }); + + it('should handle special regex characters in the query', () => { + const text = 'This is a [test] string'; + const query = '[test]'; + render(<>{highlightMatch(text, query)}); + + const highlighted = screen.getByText('[test]'); + expect(highlighted).toHaveStyle('font-weight: normal'); + }); + + it('should not highlight anything if the query does not match', () => { + const text = 'This is a test string'; + const query = 'notfound'; + const { container } = render(<>{highlightMatch(text, query)}); + + expect(container.textContent).toBe(text); + }); +}); 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 0aa6e864e..218fcebad 100644 --- a/workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx +++ b/workspaces/global-header/plugins/global-header/src/utils/stringUtils.tsx @@ -26,7 +26,11 @@ import Typography from '@mui/material/Typography'; export const highlightMatch = (text: string, query: string) => { if (!query) return <>{text}; - const regex = new RegExp(`(${query})`, 'i'); + const escapeRegex = (input: string) => + input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedQuery = escapeRegex(query); + + const regex = new RegExp(`(${escapedQuery})`, 'i'); const parts = text.split(regex); return ( @@ -53,3 +57,7 @@ export const highlightMatch = (text: string, query: string) => { ); }; + +export const createSearchLink = (searchTerm: string) => { + return `/search?query=${encodeURIComponent(searchTerm)}`; +};