diff --git a/.changeset/ninety-coats-learn.md b/.changeset/ninety-coats-learn.md new file mode 100644 index 0000000000..19d56aa128 --- /dev/null +++ b/.changeset/ninety-coats-learn.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fix the relative `sub-paths` by concatenating the app's base URL with them. diff --git a/.changeset/wicked-feet-poke.md b/.changeset/wicked-feet-poke.md deleted file mode 100644 index 533b415cd3..0000000000 --- a/.changeset/wicked-feet-poke.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-catalog': patch -'@backstage/plugin-search-react': patch -'@backstage/plugin-techdocs': patch ---- - -Fix the relative search result locations by concatenating the app's base URL into it. diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 116b33c598..6cbff05709 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -372,6 +372,9 @@ export type FlatRoutesProps = { children: ReactNode; }; +// @public +export function getBasePath(configApi: Config): string; + // @public export class GithubAuth { // (undocumented) diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index f47ddf676a..25aa606e4e 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -95,8 +95,9 @@ const InternalAppContext = createContext<{ * Get the app base path from the configured app baseUrl. * * The returned path does not have a trailing slash. + * @public */ -function getBasePath(configApi: Config) { +export function getBasePath(configApi: Config) { let { pathname } = new URL( configApi.getOptionalString('app.baseUrl') ?? '/', 'http://dummy.dev', // baseUrl can be specified as just a path diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts index 7843b36339..4d4ddf7e8b 100644 --- a/packages/core-app-api/src/app/index.ts +++ b/packages/core-app-api/src/app/index.ts @@ -16,4 +16,5 @@ export { createSpecializedApp } from './createSpecializedApp'; export { defaultConfigLoader } from './defaultConfigLoader'; +export { getBasePath } from './AppManager'; export * from './types'; diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 51cbc37a1b..0202904448 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@backstage/config": "^1.0.1", + "@backstage/core-app-api": "^1.0.4-next.0", "@backstage/core-plugin-api": "^1.0.3", "@backstage/errors": "^1.1.0-next.0", "@backstage/theme": "^0.2.16-next.0", diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index 8c6ed54d76..5cfeaa4869 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -21,9 +21,11 @@ import { TestApiProvider, wrapInTestApp, } from '@backstage/test-utils'; -import { analyticsApiRef } from '@backstage/core-plugin-api'; -import { isExternalUri, Link } from './Link'; +import { analyticsApiRef, configApiRef } from '@backstage/core-plugin-api'; +import { isExternalUri, Link, useResolvedPath } from './Link'; import { Route, Routes } from 'react-router'; +import { renderHook, WrapperComponent } from '@testing-library/react-hooks'; +import { ConfigReader } from '@backstage/config'; describe('', () => { it('navigates using react-router', async () => { @@ -103,6 +105,58 @@ describe('', () => { }); }); + describe('resolves a sub-path correctly', () => { + it('when it starts with base path', async () => { + const testString = 'This is test string'; + const linkText = 'Navigate!'; + const configApi = new ConfigReader({ + app: { baseUrl: 'http://localhost:3000/example' }, + }); + + const { getByText } = render( + wrapInTestApp( + + + {linkText} + {testString}

} /> +
+
, + ), + ); + + expect(() => getByText(testString)).toThrow(); + fireEvent.click(getByText(linkText)); + await waitFor(() => { + expect(getByText(testString)).toBeInTheDocument(); + }); + }); + + it('when it does not start with base path', async () => { + const testString = 'This is test string'; + const linkText = 'Navigate!'; + const configApi = new ConfigReader({ + app: { baseUrl: 'http://localhost:3000/example' }, + }); + + const { getByText } = render( + wrapInTestApp( + + + {linkText} + {testString}

} /> +
+
, + ), + ); + + expect(() => getByText(testString)).toThrow(); + fireEvent.click(getByText(linkText)); + await waitFor(() => { + expect(getByText(testString)).toBeInTheDocument(); + }); + }); + }); + describe('isExternalUri', () => { it.each([ [true, 'http://'], @@ -128,4 +182,45 @@ describe('', () => { expect(isExternalUri(uri)).toBe(expected); }); }); + + describe('useResolvedPath', () => { + const wrapper: WrapperComponent<{}> = ({ children }) => { + const configApi = new ConfigReader({ + app: { baseUrl: 'http://localhost:3000/example' }, + }); + return ( + + {children} + + ); + }; + + describe('concatenate base path', () => { + it('when uri is internal and does not start with base path', () => { + const path = '/catalog/default/component/artist-lookup'; + const { result } = renderHook(() => useResolvedPath(path), { + wrapper, + }); + expect(result.current).toBe('/example'.concat(path)); + }); + }); + + describe('does not concatenate base path', () => { + it('when uri is external', () => { + const path = 'https://stackoverflow.com/questions/1/example'; + const { result } = renderHook(() => useResolvedPath(path), { + wrapper, + }); + expect(result.current).toBe(path); + }); + + it('when uri already starts with base path', () => { + const path = '/example/catalog/default/component/artist-lookup'; + const { result } = renderHook(() => useResolvedPath(path), { + wrapper, + }); + expect(result.current).toBe(path); + }); + }); + }); }); diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index 5809990b92..d404755b54 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useAnalytics } from '@backstage/core-plugin-api'; +import { configApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api'; import classnames from 'classnames'; import MaterialLink, { LinkProps as MaterialLinkProps, @@ -25,6 +25,7 @@ import { Link as RouterLink, LinkProps as RouterLinkProps, } from 'react-router-dom'; +import { getBasePath } from '@backstage/core-app-api'; const useStyles = makeStyles( { @@ -52,6 +53,20 @@ export type LinkProps = MaterialLinkProps & noTrack?: boolean; }; +export const useResolvedPath = (uri: LinkProps['to']) => { + const configApi = useApi(configApiRef); + const basePath = getBasePath(configApi); + + let resolvedPath = String(uri); + const external = isExternalUri(resolvedPath); + + if (!external && !resolvedPath.startsWith(basePath)) { + resolvedPath = basePath.concat(resolvedPath); + } + + return resolvedPath; +}; + /** * Given a react node, try to retrieve its text content. */ @@ -84,7 +99,7 @@ export const Link = React.forwardRef( ({ onClick, noTrack, ...props }, ref) => { const classes = useStyles(); const analytics = useAnalytics(); - const to = String(props.to); + const to = useResolvedPath(props.to); const linkText = getNodeText(props.children) || to; const external = isExternalUri(to); const newWindow = external && !!/^https?:/.exec(to); @@ -99,11 +114,11 @@ export const Link = React.forwardRef( return external ? ( // External links {props.children} @@ -112,10 +127,11 @@ export const Link = React.forwardRef( ) : ( // Interact with React Router for internal links ); }, diff --git a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx index cc9dc0f441..a7307055c2 100644 --- a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx +++ b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx @@ -30,10 +30,7 @@ import { IndexableDocument, ResultHighlight, } from '@backstage/plugin-search-common'; -import { - HighlightedSearchResultText, - useSearchResultLocation, -} from '@backstage/plugin-search-react'; +import { HighlightedSearchResultText } from '@backstage/plugin-search-react'; const useStyles = makeStyles({ flexContainer: { @@ -66,18 +63,15 @@ export function CatalogSearchResultListItem( const classes = useStyles(); const analytics = useAnalytics(); - - const to = useSearchResultLocation(result); - const handleClick = () => { analytics.captureEvent('discover', result.title, { - attributes: { to }, + attributes: { to: result.location }, value: props.rank, }); }; return ( - + {props.icon && {props.icon}}
diff --git a/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx index 2e81f50d2e..f4260525d6 100644 --- a/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx +++ b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx @@ -29,7 +29,6 @@ import { Divider, } from '@material-ui/core'; import { Link } from '@backstage/core-components'; -import { useSearchResultLocation } from './useSearchResultLocation'; /** * Props for {@link DefaultResultListItem} @@ -59,18 +58,15 @@ export const DefaultResultListItemComponent = ({ lineClamp = 5, }: DefaultResultListItemProps) => { const analytics = useAnalytics(); - - const to = useSearchResultLocation(result); - const handleClick = () => { analytics.captureEvent('discover', result.title, { - attributes: { to }, + attributes: { to: result.location }, value: rank, }); }; return ( - + {icon && {icon}} { - const baseUrl = 'http://localhost:3000/example'; - const configApiMock = new ConfigReader({ app: { baseUrl } }); - const wrapper: WrapperComponent<{}> = ({ children }) => ( - - {children} - - ); - - it('should concatenate base url into relative urls', () => { - const document = { - title: 'artist-lookup', - text: 'Artist Lookup', - location: '/catalog/default/component/artist-lookup', - }; - - const { result } = renderHook(() => useSearchResultLocation(document), { - wrapper, - }); - - expect(result.current).toBe(baseUrl.concat(document.location)); - }); - - it('should not concatenate base url into absolute urls', () => { - const document = { - title: 'What is Backstage?', - text: 'An open platform for building developer portals ', - location: 'https://stackoverflow.com/questions/1/what-is-backstage', - }; - - const { result } = renderHook(() => useSearchResultLocation(document), { - wrapper, - }); - - expect(result.current).toBe(document.location); - }); -}); diff --git a/plugins/search-react/src/components/DefaultResultListItem/useSearchResultLocation.tsx b/plugins/search-react/src/components/DefaultResultListItem/useSearchResultLocation.tsx deleted file mode 100644 index c80d335354..0000000000 --- a/plugins/search-react/src/components/DefaultResultListItem/useSearchResultLocation.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2022 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 { configApiRef, useApi } from '@backstage/core-plugin-api'; -import { SearchDocument } from '@backstage/plugin-search-common'; - -/** - * Builds a search result url. - * @param document - A search result item. - * @returns Add app base url as location prefix when location is relative. - */ -export const useSearchResultLocation = (document: SearchDocument) => { - const configApi = useApi(configApiRef); - - let location = document.location; - - const isRelative = !location.match(/^([a-z]*:)?\/\//i); - if (isRelative) { - location = configApi.getString('app.baseUrl').concat(location); - } - - return location; -}; diff --git a/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx b/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx index bd92bde88c..430165bf8b 100644 --- a/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx +++ b/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx @@ -25,10 +25,7 @@ import { import { Link } from '@backstage/core-components'; import { useAnalytics } from '@backstage/core-plugin-api'; import { ResultHighlight } from '@backstage/plugin-search-common'; -import { - HighlightedSearchResultText, - useSearchResultLocation, -} from '@backstage/plugin-search-react'; +import { HighlightedSearchResultText } from '@backstage/plugin-search-react'; const useStyles = makeStyles({ flexContainer: { @@ -77,12 +74,9 @@ export const TechDocsSearchResultListItem = ( const classes = useStyles(); const analytics = useAnalytics(); - - const to = useSearchResultLocation(result); - const handleClick = () => { analytics.captureEvent('discover', result.title, { - attributes: { to }, + attributes: { to: result.location }, value: rank, }); }; @@ -157,7 +151,7 @@ export const TechDocsSearchResultListItem = ( const LinkWrapper = ({ children }: PropsWithChildren<{}>) => asLink ? ( - + {children} ) : (