diff --git a/.changeset/ninety-coats-learn.md b/.changeset/ninety-coats-learn.md
new file mode 100644
index 0000000000..9d6945e9c0
--- /dev/null
+++ b/.changeset/ninety-coats-learn.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-components': patch
+---
+
+Fix relative `sub-paths` by concatenating the app's base path with them.
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..22b203d157 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 { trimEnd } from 'lodash';
const useStyles = makeStyles(
{
@@ -52,6 +53,45 @@ export type LinkProps = MaterialLinkProps &
noTrack?: boolean;
};
+/**
+ * Returns the app base url that could be empty if the Config API is not properly implemented.
+ * The only cases there would be no Config API are in tests and in storybook stories, and in those cases, it's unlikely that callers would rely on this subpath behavior.
+ */
+const useBaseUrl = () => {
+ try {
+ const config = useApi(configApiRef);
+ return config.getOptionalString('app.baseUrl');
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Get the app base path from the configured app baseUrl.
+ * The returned path does not have a trailing slash.
+ */
+const useBasePath = () => {
+ // baseUrl can be specified as just a path
+ const base = 'http://dummy.dev';
+ const url = useBaseUrl() ?? '/';
+ const { pathname } = new URL(url, base);
+ return trimEnd(pathname, '/');
+};
+
+export const useResolvedPath = (uri: LinkProps['to']) => {
+ let resolvedPath = String(uri);
+
+ const basePath = useBasePath();
+ const external = isExternalUri(resolvedPath);
+ const startsWithBasePath = resolvedPath.startsWith(basePath);
+
+ if (!external && !startsWithBasePath) {
+ resolvedPath = basePath.concat(resolvedPath);
+ }
+
+ return resolvedPath;
+};
+
/**
* Given a react node, try to retrieve its text content.
*/
@@ -84,7 +124,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 +139,11 @@ export const Link = React.forwardRef(
return external ? (
// External links
{props.children}
@@ -112,10 +152,11 @@ export const Link = React.forwardRef(
) : (
// Interact with React Router for internal links
);
},