Add UI element to notify user before redirect occurs. (#25911)

* Add UI element to notify user before redirect occurs. Allow user to skip to redirect from notification.
---------

Signed-off-by: Sydney Achinger <sydneynicoleachinger@spotify.com>
This commit is contained in:
Sydney Achinger
2024-08-06 20:55:39 -04:00
committed by GitHub
parent 2c08403f61
commit 8543e723a5
4 changed files with 160 additions and 60 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
TechDocs redirect feature now includes a notification to the user before they are redirected.
@@ -16,6 +16,7 @@
import { handleMetaRedirects } from './handleMetaRedirects';
import { createTestShadowDom } from '../../test-utils';
import { screen } from '@testing-library/react';
describe('handleMetaRedirects', () => {
const navigate = jest.fn();
@@ -35,15 +36,19 @@ describe('handleMetaRedirects', () => {
},
writable: true,
});
return await createTestShadowDom(html, {
preTransformers: [],
postTransformers: [handleMetaRedirects(navigate, entityName)],
});
};
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
document.body.innerHTML = '';
});
it('should navigate to relative URL if meta redirect tag is present', async () => {
@@ -52,6 +57,13 @@ describe('handleMetaRedirects', () => {
'http://localhost/docs/default/component/testEntity/subpath',
'/docs/default/component/testEntity/subpath',
);
expect(
await screen.findByText(
'This TechDocs page is no longer maintained. Will automatically redirect to the designated replacement.',
),
).toBeInTheDocument();
jest.runAllTimers();
expect(navigate).toHaveBeenCalledWith(
'http://localhost/docs/default/component/testEntity/anotherPage',
);
@@ -63,6 +75,13 @@ describe('handleMetaRedirects', () => {
'http://localhost/docs/default/component/testEntity/subpath',
'/docs/default/component/testEntity/subpath',
);
expect(
await screen.findByText(
'This TechDocs page is no longer maintained. Will automatically redirect to the designated replacement.',
),
).toBeInTheDocument();
jest.runAllTimers();
expect(navigate).toHaveBeenCalledWith('/docs/default/component/testEntity');
});
@@ -72,6 +91,13 @@ describe('handleMetaRedirects', () => {
'http://localhost/docs/default/component/testEntity/subpath',
'/docs/default/component/testEntity/subpath',
);
expect(
await screen.findByText(
'This TechDocs page is no longer maintained. Will automatically redirect to the designated replacement.',
),
).toBeInTheDocument();
jest.runAllTimers();
expect(navigate).toHaveBeenCalledWith('http://localhost/test');
});
@@ -81,6 +107,8 @@ describe('handleMetaRedirects', () => {
'http://localhost/docs/default/component/testEntity/subpath',
'/docs/default/component/testEntity/subpath',
);
jest.runAllTimers();
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -1,59 +0,0 @@
/*
* Copyright 2024 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 { Transformer } from './transformer';
import { normalizeUrl } from './rewriteDocLinks';
export const handleMetaRedirects = (
navigate: (to: string) => void,
entityName: string,
): Transformer => {
return dom => {
for (const elem of Array.from(dom.querySelectorAll('meta'))) {
if (elem.getAttribute('http-equiv') === 'refresh') {
const metaContentParameters = elem
.getAttribute('content')
?.split('url=');
if (!metaContentParameters || metaContentParameters.length < 2) {
continue;
}
const metaUrl = metaContentParameters[1];
const normalizedCurrentUrl = normalizeUrl(window.location.href);
// If metaUrl is relative, it will be resolved with base href. If it is absolute, it will replace the base href when creating URL object.
const absoluteRedirectObj = new URL(metaUrl, normalizedCurrentUrl);
const isExternalRedirect =
absoluteRedirectObj.hostname !== window.location.hostname;
if (isExternalRedirect) {
// If the redirect is external, navigate to the documentation site home instead of the external url.
const currentTechDocPath = window.location.pathname;
const indexOfSiteHome = currentTechDocPath.indexOf(entityName);
const siteHomePath = currentTechDocPath.slice(
0,
indexOfSiteHome + entityName.length,
);
navigate(siteHomePath);
} else {
// The navigate function from dom.tsx is a wrapper around react-router navigate function that helps absolute url redirects.
navigate(absoluteRedirectObj.href);
}
return dom;
}
}
return dom;
};
};
@@ -0,0 +1,126 @@
/*
* Copyright 2024 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 { Transformer } from './transformer';
import { normalizeUrl } from './rewriteDocLinks';
import Snackbar from '@material-ui/core/Snackbar';
import React, { useState } from 'react';
import { renderReactElement } from './renderReactElement';
import Button from '@material-ui/core/Button';
import { makeStyles } from '@material-ui/core/styles';
type RedirectNotificationProps = {
handleButtonClick: () => void;
message: string;
autoHideDuration: number;
};
const useStyles = makeStyles(theme => ({
button: {
color: theme.palette.primary.light,
textDecoration: 'underline',
},
}));
const RedirectNotification = ({
message,
handleButtonClick,
autoHideDuration,
}: RedirectNotificationProps) => {
const classes = useStyles();
const [open, setOpen] = useState(true);
const handleClose = () => {
setOpen(prev => !prev);
};
return (
<Snackbar
open={open}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
autoHideDuration={autoHideDuration}
color="primary"
onClose={handleClose}
message={message}
action={
<Button
classes={{ root: classes.button }}
size="small"
onClick={handleButtonClick}
>
Redirect now
</Button>
}
/>
);
};
export const handleMetaRedirects = (
navigate: (to: string) => void,
entityName: string,
): Transformer => {
const redirectAfterMs = 4000;
const determineRedirectURL = (metaUrl: string) => {
const normalizedCurrentUrl = normalizeUrl(window.location.href);
// If metaUrl is relative, it will be resolved with base href. If it is absolute, it will replace the base href when creating URL object.
const absoluteRedirectObj = new URL(metaUrl, normalizedCurrentUrl);
const isExternalRedirect =
absoluteRedirectObj.hostname !== window.location.hostname;
if (isExternalRedirect) {
const currentTechDocPath = window.location.pathname;
const indexOfSiteHome = currentTechDocPath.indexOf(entityName);
const siteHomePath = currentTechDocPath.slice(
0,
indexOfSiteHome + entityName.length,
);
return siteHomePath;
}
// The navigate function from dom.tsx is a wrapper around react-router navigate function that helps absolute url redirects.
return absoluteRedirectObj.href;
};
return dom => {
for (const elem of Array.from(dom.querySelectorAll('meta'))) {
if (elem.getAttribute('http-equiv') === 'refresh') {
const metaContentParameters = elem
.getAttribute('content')
?.split('url=');
if (!metaContentParameters || metaContentParameters.length < 2) {
continue;
}
const metaUrl = metaContentParameters[1];
const redirectURL = determineRedirectURL(metaUrl);
const container = document.createElement('div');
renderReactElement(
<RedirectNotification
message="This TechDocs page is no longer maintained. Will automatically redirect to the designated replacement."
handleButtonClick={() => navigate(redirectURL)}
autoHideDuration={redirectAfterMs}
/>,
container,
);
document.body.appendChild(container);
setTimeout(() => {
navigate(redirectURL);
}, redirectAfterMs);
return dom;
}
}
return dom;
};
};