Skip to content

Commit

Permalink
Merge pull request #244 from adhocteam/177-display-external-link-warning
Browse files Browse the repository at this point in the history
Display external link warning
  • Loading branch information
gopar authored Mar 19, 2021
2 parents f7074ce + 61e2269 commit 6c28851
Show file tree
Hide file tree
Showing 8 changed files with 356 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ parameters:
default: "main"
type: string
sandbox_git_branch: # change to feature branch to test deployment
default: "js-392-small-fixes"
default: "177-display-external-link-warning"
type: string
prod_new_relic_app_id:
default: "877570491"
Expand Down
1 change: 1 addition & 0 deletions frontend/.env
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
1 change: 1 addition & 0 deletions frontend/src/Constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@ export const REPORT_STATUSES = {

export const REPORTS_PER_PAGE = 10;
export const ALERTS_PER_PAGE = 10;
export const GOVERNMENT_HOSTNAME_EXTENSION = '.ohs.acf.hhs.gov';
108 changes: 108 additions & 0 deletions frontend/src/components/ExternalResourceModal.js
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 frontend/src/components/__tests__/ExternalResourceModal.js
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();
});
});
});
18 changes: 18 additions & 0 deletions frontend/src/pages/ActivityReport/Pages/Review/ReviewItem.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { Link } from 'react-router-dom';
import { useFormContext } from 'react-hook-form/dist/index.ie11';

import { ExternalLink } from '../../../../components/ExternalResourceModal';
import { isValidURL, isExternalURL, isInternalGovernmentLink } from '../../../../utils';

const ReviewItem = ({ label, name, path }) => {
const { watch } = useFormContext();
const value = watch(name);
Expand All @@ -16,6 +20,20 @@ const ReviewItem = ({ label, name, path }) => {
values = values.map((v) => _.get(v, path));
}

values = values.map((v) => {
// If not a valid url, then its most likely just text, so leave it as is
if (!isValidURL(v)) {
return v;
}

if (isExternalURL(v) || isInternalGovernmentLink(v)) {
return <ExternalLink to={v}>{v}</ExternalLink>;
}

const localLink = new URL(v);
return <Link to={localLink.pathname}>{v}</Link>;
});

const emptySelector = value && value.length > 0 ? '' : 'smart-hub-review-item--empty';
const classes = ['margin-top-1', emptySelector].filter((x) => x !== '').join(' ');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const sections = [
name: 'object',
path: 'test',
},
{
label: 'link',
name: 'link',
},
],
},
{
Expand All @@ -37,6 +41,7 @@ const values = {
array: ['one', 'two'],
single: 'value',
object: { test: 'test' },
link: 'https://www.google.com/awesome',
};

const RenderReviewPage = () => {
Expand Down Expand Up @@ -88,6 +93,11 @@ describe('ReviewPage', () => {
expect(value).toHaveTextContent('value');
});

it('displays link values', async () => {
const value = await screen.findByLabelText('link 1');
expect(value).toHaveTextContent('https://www.google.com/awesome');
});

it('displays an objects value (via method call)', async () => {
const value = await screen.findByLabelText('object 1');
expect(value).toHaveTextContent('test');
Expand Down
Loading

0 comments on commit 6c28851

Please sign in to comment.