Fixing techdocs linking issue during dom link overwrites.

Signed-off-by: Aramis Sennyey <sennyeya@amazon.com>
This commit is contained in:
Aramis Sennyey
2023-03-01 18:21:31 -05:00
parent a9b1daf979
commit 2e49348062
8 changed files with 222 additions and 9 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-app-api': minor
'@backstage/plugin-techdocs': patch
---
Fix a bug in sub-path navigation due to double addition of a sub-path if one was set up in `app.baseUrl`.
+3
View File
@@ -576,6 +576,9 @@ export class UrlPatternDiscovery implements DiscoveryApi {
getBaseUrl(pluginId: string): Promise<string>;
}
// @public
export function useNavigateUrl(): (to: string) => void;
// @public
export class WebStorage implements StorageApi {
constructor(namespace: string, errorApi: ErrorApi);
@@ -18,3 +18,5 @@ export { FlatRoutes } from './FlatRoutes';
export type { FlatRoutesProps } from './FlatRoutes';
export { FeatureFlagged } from './FeatureFlagged';
export type { FeatureFlaggedProps } from './FeatureFlagged';
export { useNavigateUrl } from './useNavigateUrl';
@@ -0,0 +1,120 @@
/*
* Copyright 2023 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 {
MockConfigApi,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { resolveUrlToRelative, useNavigateUrl } from './useNavigateUrl';
import { configApiRef } from '@backstage/core-plugin-api';
const navigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => navigate,
}));
describe('resolveUrlToRelative', () => {
it('does nothing when app.baseUrl has no subpath', () => {
const url = 'http://localhost:3000/test';
const baseUrl = 'http://localhost:3000';
expect(resolveUrlToRelative(url, baseUrl)).toBe('/test');
});
it('removes the app.baseUrl subpath when present', () => {
const url = 'http://localhost:3000/instance/test';
const baseUrl = 'http://localhost:3000/instance';
expect(resolveUrlToRelative(url, baseUrl)).toBe('/test');
});
it('removes trailing slashes on the URL when present', () => {
const url = 'http://localhost:3000/test//';
const baseUrl = 'http://localhost:3000';
expect(resolveUrlToRelative(url, baseUrl)).toBe('/test');
});
});
const Component = ({ to }: { to: string }) => {
const navigateTo = useNavigateUrl();
return <>{navigateTo(to)}</>;
};
describe('useNavigateUrl', () => {
beforeEach(() => {
navigate.mockReset();
});
it('navigates to the desired page as expected', async () => {
const baseUrl = 'http://localhost:3000';
await renderInTestApp(
<TestApiProvider
apis={[
[
configApiRef,
new MockConfigApi({
app: {
baseUrl,
},
}),
],
]}
>
<Component to={`${baseUrl}/test`} />
</TestApiProvider>,
);
expect(navigate).toHaveBeenCalledWith('/test');
});
it('handles app.baseUrl subpaths', async () => {
const baseUrl = 'http://localhost:3000/instance';
await renderInTestApp(
<TestApiProvider
apis={[
[
configApiRef,
new MockConfigApi({
app: {
baseUrl,
},
}),
],
]}
>
<Component to={`${baseUrl}/test`} />
</TestApiProvider>,
);
expect(navigate).toHaveBeenCalledWith('/test');
});
it('handles relative urls', async () => {
const baseUrl = 'http://localhost:3000';
await renderInTestApp(
<TestApiProvider
apis={[
[
configApiRef,
new MockConfigApi({
app: {
baseUrl,
},
}),
],
]}
>
<Component to="/test" />
</TestApiProvider>,
);
expect(navigate).toHaveBeenCalledWith('/test');
});
});
@@ -0,0 +1,71 @@
/*
* Copyright 2023 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 { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
/**
* Resolve a URL to a relative URL given a base URL that may or may not include subpaths.
* @param url - URL to parse into a relative url based on the baseUrl.
* @param baseUrl - Application base url, where the application is currently hosted.
* @returns relative path without any subpaths from website config.
*/
export function resolveUrlToRelative(url: string, baseUrl: string) {
const parsedAppUrl = new URL(baseUrl);
const appUrlPath = `${parsedAppUrl.origin}${parsedAppUrl.pathname.replace(
/\/$/,
'',
)}`;
const relativeUrl = url
.replace(appUrlPath, '')
// Remove any leading and trailing slashes.
.replace(/\/+$/, '')
.replace(/^\/+/, '');
const parsedUrl = new URL(`http://localhost/${relativeUrl}`);
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
}
/**
* A helper hook that allows for full internal website urls to be processed through the navigate
* hook provided by `react-router-dom`.
*
* NOTE: This does not support routing to external URLs. That should be done with a `Link` or `a`
* element instead, or just `window.location.href`.
*
* @returns Navigation function that is a wrapper over `react-router-dom`'s
* to support passing full URLs for navigation.
*
* @public
*/
export function useNavigateUrl() {
const navigate = useNavigate();
const configApi = useApi(configApiRef);
const appBaseUrl = configApi.getString('app.baseUrl');
const navigateFn = useCallback(
(to: string) => {
let url = to;
try {
url = resolveUrlToRelative(to, appBaseUrl);
} catch (err) {
// URL passed in was relative.
}
navigate(url);
},
[navigate, appBaseUrl],
);
return navigateFn;
}
+1 -1
View File
@@ -35,6 +35,7 @@
"dependencies": {
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
@@ -65,7 +66,6 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/plugin-techdocs-module-addons-contrib": "workspace:^",
"@backstage/test-utils": "workspace:^",
@@ -18,7 +18,11 @@ import { act } from '@testing-library/react';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
MockConfigApi,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { techdocsApiRef, techdocsStorageApiRef } from '../../../api';
@@ -31,6 +35,7 @@ import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib';
import { FlatRoutes } from '@backstage/core-app-api';
import { Page } from '@backstage/core-components';
import { configApiRef } from '@backstage/core-plugin-api';
const mockEntityMetadata = {
locationMetadata: {
@@ -80,11 +85,18 @@ jest.mock('@backstage/core-components', () => ({
Page: jest.fn(),
}));
const configApi = new MockConfigApi({
app: {
baseUrl: 'http://localhost:3000',
},
});
const Wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<TestApiProvider
apis={[
[scmIntegrationsApiRef, {}],
[configApiRef, configApi],
[techdocsApiRef, techdocsApiMock],
[techdocsStorageApiRef, techdocsStorageApiMock],
]}
@@ -15,7 +15,6 @@
*/
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme, useMediaQuery } from '@material-ui/core';
@@ -47,6 +46,7 @@ import {
useSanitizerTransformer,
useStylesTransformer,
} from '../../transformers';
import { useNavigateUrl } from '@backstage/core-app-api';
const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
@@ -58,7 +58,7 @@ const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
export const useTechDocsReaderDom = (
entityRef: CompoundEntityRef,
): Element | null => {
const navigate = useNavigate();
const navigate = useNavigateUrl();
const theme = useTheme<BackstageTheme>();
const isMobileMedia = useMediaQuery(MOBILE_MEDIA_QUERY);
const sanitizerTransformer = useSanitizerTransformer();
@@ -176,7 +176,6 @@ export const useTechDocsReaderDom = (
// detect if CTRL or META keys are pressed so that links can be opened in a new tab with `window.open`
const modifierActive = event.ctrlKey || event.metaKey;
const parsedUrl = new URL(url);
const fullPath = `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
// capture link clicks within documentation
const linkText =
@@ -187,9 +186,9 @@ export const useTechDocsReaderDom = (
// hash exists when anchor is clicked on secondary sidebar
if (parsedUrl.hash) {
if (modifierActive) {
window.open(fullPath, '_blank');
window.open(url, '_blank');
} else {
navigate(fullPath);
navigate(url);
// Scroll to hash if it's on the current page
transformedElement
?.querySelector(`[id="${parsedUrl.hash.slice(1)}"]`)
@@ -197,9 +196,9 @@ export const useTechDocsReaderDom = (
}
} else {
if (modifierActive) {
window.open(fullPath, '_blank');
window.open(url, '_blank');
} else {
navigate(fullPath);
navigate(url);
}
}
},