diff --git a/.changeset/eight-insects-kiss.md b/.changeset/eight-insects-kiss.md new file mode 100644 index 0000000000..4b09829bf3 --- /dev/null +++ b/.changeset/eight-insects-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Items in `` are now only active when their full path is active (including search parameters). diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index dac35bd3a7..7d00db1b1a 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -46,6 +46,8 @@ import { SidebarItemWithSubmenuContext, } from './config'; import { SidebarSubmenu } from './SidebarSubmenu'; +import { isLocationMatch } from './utils'; +import { Location } from 'history'; export type SidebarItemClassKey = | 'root' @@ -172,7 +174,7 @@ const useStyles = makeStyles( function isSidebarItemWithSubmenuActive( submenu: ReactNode, - locationPathname: string, + currentLocation: Location, ) { // Item is active if any of submenu items have active paths const toPathnames: string[] = []; @@ -193,8 +195,8 @@ function isSidebarItemWithSubmenuActive( } }); isActive = toPathnames.some(to => { - const toPathname = resolvePath(to); - return locationPathname === toPathname.pathname; + const toLocation = resolvePath(to); + return isLocationMatch(currentLocation, toLocation); }); return isActive; } @@ -207,8 +209,8 @@ const SidebarItemWithSubmenu = ({ }: PropsWithChildren) => { const classes = useStyles(); const [isHoveredOn, setIsHoveredOn] = useState(false); - const { pathname: locationPathname } = useLocation(); - const isActive = isSidebarItemWithSubmenuActive(children, locationPathname); + const currentLocation = useLocation(); + const isActive = isSidebarItemWithSubmenuActive(children, currentLocation); const handleMouseEnter = () => { setIsHoveredOn(true); diff --git a/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx index 5e68aa8d62..6afd0bf157 100644 --- a/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx +++ b/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx @@ -17,8 +17,6 @@ import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined'; import BuildRoundedIcon from '@material-ui/icons/BuildRounded'; -import LibraryBooksOutlinedIcon from '@material-ui/icons/LibraryBooksOutlined'; -import WebOutlinedIcon from '@material-ui/icons/WebOutlined'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { @@ -34,7 +32,7 @@ import { import { SidebarSubmenuItem } from './SidebarSubmenuItem'; import MenuBookIcon from '@material-ui/icons/MenuBook'; import CloudQueueIcon from '@material-ui/icons/CloudQueue'; -import SettingsApplications from '@material-ui/icons/SettingsApplications'; +import AppsIcon from '@material-ui/icons/Apps'; import AcUnitIcon from '@material-ui/icons/AcUnit'; import { SidebarSubmenu } from './SidebarSubmenu'; @@ -76,17 +74,7 @@ export const SampleScalableSidebar = () => ( - - - + (theme => ({ item: { @@ -116,14 +117,13 @@ export type SidebarSubmenuItemProps = { export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => { const { title, to, icon: Icon, dropdownItems } = props; const classes = useStyles(); - const { pathname: locationPathname } = useLocation(); - const { pathname: toPathname } = useResolvedPath(to); const { setIsHoveredOn } = useContext(SidebarItemWithSubmenuContext); const closeSubmenu = () => { setIsHoveredOn(false); }; - - let isActive = locationPathname === toPathname; + const toLocation = useResolvedPath(to); + const currentLocation = useLocation(); + let isActive = isLocationMatch(currentLocation, toLocation); const [showDropDown, setShowDropDown] = useState(false); const handleClickDropdown = () => { @@ -132,7 +132,8 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => { if (dropdownItems !== undefined) { dropdownItems.some(item => { const resolvedPath = resolvePath(item.to); - isActive = locationPathname === resolvedPath.pathname; + isActive = isLocationMatch(currentLocation, resolvedPath); + return isActive; }); return (
diff --git a/packages/core-components/src/layout/Sidebar/utils.test.ts b/packages/core-components/src/layout/Sidebar/utils.test.ts new file mode 100644 index 0000000000..b08dcda0fc --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/utils.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2021 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 { Location, Path } from 'history'; +import { isLocationMatch } from './utils'; + +describe('isLocationMatching', () => { + let currentLocation: Location; + let toLocation: Path; + + it('return false when pathname in target and current location differ', async () => { + currentLocation = { + pathname: '/catalog', + search: '?kind=component', + state: null, + hash: '', + key: '', + }; + toLocation = { + pathname: '/catalog-a', + search: '?kind=component', + hash: '', + }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(false); + }); + + it('return true when exact match between current and target location parameters', async () => { + currentLocation = { + pathname: '/catalog', + search: '?kind=component', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '?kind=component', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(true); + }); + + it('return true when target query parameters are subset of current location query parameters', async () => { + currentLocation = { + pathname: '/catalog', + search: '?x=foo&y=bar', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '?x=foo', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(true); + }); + + it('return false when no matching query parameters between target and current location', async () => { + currentLocation = { + pathname: '/catalog', + search: '?y=bar', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '?x=foo', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(false); + }); + + it('return true when query parameters match in different order', async () => { + currentLocation = { + pathname: '/catalog', + search: '?y=bar&x=foo', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '?x=foo&y=bar', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(true); + }); + + it('return true when there is a matching query parameter alongside extra parameters', async () => { + currentLocation = { + pathname: '/catalog', + search: '?y=bar&x=foo', + state: null, + hash: '', + key: '', + }; + toLocation = { pathname: '/catalog', search: '', hash: '' }; + expect(isLocationMatch(currentLocation, toLocation)).toBe(true); + }); +}); diff --git a/packages/core-components/src/layout/Sidebar/utils.ts b/packages/core-components/src/layout/Sidebar/utils.ts new file mode 100644 index 0000000000..1afebe796d --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/utils.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 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 { Location, Path } from 'history'; +import { isEqual, isMatch } from 'lodash'; +import qs from 'qs'; + +export function isLocationMatch(currentLocation: Location, toLocation: Path) { + const toDecodedSearch = new URLSearchParams(toLocation.search).toString(); + const toQueryParameters = qs.parse(toDecodedSearch); + + const currentDecodedSearch = new URLSearchParams( + currentLocation.search, + ).toString(); + const currentQueryParameters = qs.parse(currentDecodedSearch); + + const matching = + isEqual(toLocation.pathname, currentLocation.pathname) && + isMatch(currentQueryParameters, toQueryParameters); + + return matching; +}