Fixing techdocs linking issue during dom link overwrites.
Signed-off-by: Aramis Sennyey <sennyeya@amazon.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:^",
|
||||
|
||||
+13
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user