-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #244 from adhocteam/177-display-external-link-warning
Display external link warning
- Loading branch information
Showing
8 changed files
with
356 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
BACKEND_PROXY=http://localhost:8080 | ||
REACT_APP_INACTIVE_MODAL_TIMEOUT=1500000 | ||
REACT_APP_SESSION_TIMEOUT=1800000 | ||
REACT_APP_TTA_SMART_HUB_URI=http://localhost:3000 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
import React, { useCallback, useEffect, useRef } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { | ||
Button, Modal, Alert, useModal, connectModal, | ||
} from '@trussworks/react-uswds'; | ||
|
||
import { isValidURL, isInternalGovernmentLink } from '../utils'; | ||
|
||
const ESCAPE_KEY_CODE = 27; | ||
|
||
const ExternalResourceModal = ({ onOpen, onClose }) => ( | ||
<Modal | ||
title={<h3>External Resources Disclaimer</h3>} | ||
actions={( | ||
<> | ||
<Button type="button" onClick={onClose}> | ||
Cancel | ||
</Button> | ||
|
||
<Button type="button" secondary onClick={onOpen}> | ||
View External Resource | ||
</Button> | ||
</> | ||
)} | ||
> | ||
<Alert role="alert" type="warning"> | ||
<b>Note:</b> | ||
{' '} | ||
This link is hosted outside of an OHS-led system. | ||
OHS does not have responsibility for external content or | ||
the privacy policies of non-government websites. | ||
</Alert> | ||
</Modal> | ||
); | ||
|
||
ExternalResourceModal.propTypes = { | ||
onOpen: PropTypes.func.isRequired, | ||
onClose: PropTypes.func.isRequired, | ||
}; | ||
|
||
const ExternalLink = ({ to, children }) => { | ||
if (!isValidURL(to)) { | ||
return to; | ||
} | ||
|
||
const modalRef = useRef(null); | ||
const { isOpen, openModal, closeModal } = useModal(); | ||
|
||
const onEscape = useCallback((event) => { | ||
if (event.keyCode === ESCAPE_KEY_CODE) { | ||
closeModal(); | ||
} | ||
}, [isOpen]); | ||
|
||
useEffect(() => { | ||
document.addEventListener('keydown', onEscape, false); | ||
return () => { | ||
document.removeEventListener('keydown', onEscape, false); | ||
}; | ||
}, [onEscape]); | ||
|
||
useEffect(() => { | ||
const button = modalRef.current.querySelector('button'); | ||
if (button) { | ||
button.focus(); | ||
} | ||
}); | ||
|
||
const onClick = () => { | ||
closeModal(); | ||
window.open(to, '_blank'); | ||
}; | ||
|
||
const onLinkClick = (e) => { | ||
e.preventDefault(); | ||
if (isInternalGovernmentLink(to)) { | ||
window.open(to, '_blank'); | ||
} else { | ||
openModal(); | ||
} | ||
}; | ||
|
||
const ConnectModal = connectModal(() => ( | ||
<ExternalResourceModal onOpen={onClick} onClose={closeModal} /> | ||
)); | ||
|
||
return ( | ||
<> | ||
<div ref={modalRef} aria-modal="true" role="dialog"> | ||
<ConnectModal isOpen={isOpen} onClose={closeModal} /> | ||
</div> | ||
<a href={to} onClick={onLinkClick}> | ||
{children} | ||
{' '} | ||
</a> | ||
</> | ||
); | ||
}; | ||
|
||
ExternalLink.propTypes = { | ||
to: PropTypes.string.isRequired, | ||
children: PropTypes.node.isRequired, | ||
}; | ||
|
||
export { | ||
ExternalResourceModal, | ||
ExternalLink, | ||
}; |
174 changes: 174 additions & 0 deletions
174
frontend/src/components/__tests__/ExternalResourceModal.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
import '@testing-library/jest-dom'; | ||
import React from 'react'; | ||
import { | ||
render, screen, | ||
} from '@testing-library/react'; | ||
import userEvent from '@testing-library/user-event'; | ||
import join from 'url-join'; | ||
|
||
import { ExternalLink } from '../ExternalResourceModal'; | ||
import { isExternalURL, isValidURL } from '../../utils'; | ||
import { GOVERNMENT_HOSTNAME_EXTENSION } from '../../Constants'; | ||
|
||
let windowSpy; | ||
describe('External Resources', () => { | ||
beforeEach(() => { | ||
windowSpy = jest.spyOn(window, 'open'); | ||
}); | ||
|
||
afterEach(() => { | ||
windowSpy.mockRestore(); | ||
}); | ||
|
||
it('shows modal when an external link is clicked', async () => { | ||
// Given a external link | ||
render(<ExternalLink to="https://www.google.com">something</ExternalLink>); | ||
const link = await screen.findByText('something'); | ||
|
||
// when a users preses the link | ||
userEvent.click(link); | ||
|
||
// Then we see the modal | ||
expect(await screen.findByTestId('modal')).toBeVisible(); | ||
}); | ||
|
||
it('closes modal when cancel button is pressed', async () => { | ||
// Given an external link | ||
render(<ExternalLink to="https://www.google.com">something</ExternalLink>); | ||
const link = await screen.findByText('something'); | ||
|
||
// When the users clicks it | ||
userEvent.click(link); | ||
expect(await screen.findByTestId('modal')).toBeVisible(); | ||
|
||
// Then the user can make the modal disappear via the cancel button | ||
const cancelButton = await screen.findByText('Cancel'); | ||
userEvent.click(cancelButton); | ||
expect(screen.queryByTestId('modal')).not.toBeTruthy(); | ||
}); | ||
|
||
it('closes modal when escape key is pressed', async () => { | ||
// Given an external link | ||
render(<ExternalLink to="https://www.google.com">something</ExternalLink>); | ||
const link = await screen.findByText('something'); | ||
|
||
// When the users clicks it | ||
userEvent.click(link); | ||
const modal = await screen.findByTestId('modal'); | ||
expect(modal).toBeVisible(); | ||
|
||
// Then they try to close with delete key | ||
userEvent.type(modal, '{del}', { skipClick: true }); | ||
expect(screen.queryByTestId('modal')).toBeTruthy(); | ||
|
||
// And they can close the modal via the escape key | ||
userEvent.type(modal, '{esc}', { skipClick: true }); | ||
expect(screen.queryByTestId('modal')).not.toBeTruthy(); | ||
}); | ||
|
||
it('shows external link when ok is pressed', async () => { | ||
windowSpy.mockReturnValue(); | ||
|
||
// Given an external link | ||
render(<ExternalLink to="https://www.google.com">something</ExternalLink>); | ||
const link = await screen.findByText('something'); | ||
|
||
// When the users clicks it | ||
userEvent.click(link); | ||
const acceptButton = await screen.findByText('View External Resource'); | ||
userEvent.click(acceptButton); | ||
|
||
// Then we hide the modal | ||
expect(screen.queryByTestId('modal')).not.toBeTruthy(); | ||
|
||
// And a new tab has been opened | ||
expect(windowSpy).toHaveBeenCalledWith('https://www.google.com', '_blank'); | ||
}); | ||
|
||
it('shows internal goverment link when ok is pressed', async () => { | ||
windowSpy.mockReturnValue(); | ||
const url = `https://shrek${GOVERNMENT_HOSTNAME_EXTENSION}`; | ||
|
||
// Given an external link | ||
render(<ExternalLink to={url}>something</ExternalLink>); | ||
const link = await screen.findByText('something'); | ||
|
||
// When the users clicks it | ||
userEvent.click(link); | ||
|
||
// Then we see no modal | ||
expect(screen.queryByTestId('modal')).not.toBeTruthy(); | ||
|
||
// And a new tab has been opened | ||
expect(windowSpy).toHaveBeenCalledWith(url, '_blank'); | ||
}); | ||
|
||
it('shows normal non-hyperlink text with non-url', async () => { | ||
// Given a normal chunk of text | ||
render(<ExternalLink to="hakuna matata">The mighty lion sleeps tonight</ExternalLink>); | ||
|
||
// When the user tries to click it | ||
const text = await screen.findByText('hakuna matata'); | ||
userEvent.click(text); | ||
|
||
// Then nothing will happen b/c its plain text | ||
expect(screen.queryByTestId('modal')).not.toBeTruthy(); | ||
}); | ||
}); | ||
|
||
// For mocking `process.env`, I got it from https://stackoverflow.com/a/48042799 | ||
describe('utility functions', () => { | ||
const OLD_WINDOW = global.window; | ||
|
||
beforeEach(() => { | ||
jest.resetModules(); // it clears the cache | ||
delete global.window.location; | ||
global.window = Object.create(window); | ||
global.window.location = { | ||
host: 'government.gov', | ||
}; | ||
}); | ||
|
||
afterAll(() => { | ||
global.window.location = OLD_WINDOW; | ||
}); | ||
|
||
it('utility function correctly assumes external URLs', () => { | ||
// Given a url | ||
const url = join('https://fiona.com', 'some-internal', 'url'); | ||
|
||
// When we check if it's external | ||
// Then we see it is | ||
expect(isExternalURL(url)).toBeTruthy(); | ||
}); | ||
|
||
it('utility function correctly assumes NON-external URLs', () => { | ||
// Given a url | ||
const url = join('http://government.gov', 'some-internal', 'url'); | ||
|
||
// When we check if it's external | ||
// Then we see it is not | ||
expect(isExternalURL(url)).toBeFalsy(); | ||
}); | ||
|
||
it('utility function correctly validates internal urls', () => { | ||
// Given an internal url | ||
const internal = join('http://government.gov', 'some-internal', 'url'); | ||
|
||
// When we check if its valid | ||
// Then we see it is | ||
expect(isValidURL(internal)).toBeTruthy(); | ||
}); | ||
|
||
it('utility function correctly validates other govemernt urls', () => { | ||
const urls = ['https://shrek', 'https://www.fiona', 'http://donkey']; | ||
|
||
// Given an internal url | ||
urls.forEach((url) => { | ||
const internal = join(`${url}${GOVERNMENT_HOSTNAME_EXTENSION}`, 'some-internal', 'url'); | ||
// When we check if its valid | ||
// Then we see it is | ||
expect(isExternalURL(internal)).not.toBeTruthy(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.