Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Prod] Show validation/error message when attempting to enter a generic iPD URL #2221

Merged
merged 16 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ parameters:
type: string
dev_git_branch: # change to feature branch to test deployment
description: "Name of github branch that will deploy to dev"
default: "lr/ttahub-1521/display-regions-in-AR-header"
default: "mb/TTAHUB-3007/no-disallowed-urls"
type: string
sandbox_git_branch: # change to feature branch to test deployment
default: "al-ttahub-add-fei-root-cause-to-review"
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@silevis/reactgrid": "3.1",
"@react-hook/resize-observer": "^1.2.6",
"@trussworks/react-uswds": "4.1.1",
"@ttahub/common": "^2.1.3",
"@ttahub/common": "^2.1.5",
"@use-it/interval": "^1.0.0",
"async": "^3.2.3",
"browserslist": "^4.16.5",
Expand Down
26 changes: 9 additions & 17 deletions frontend/src/components/GoalForm/ResourceRepeater.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { isValidResourceUrl } from '@ttahub/common';
import PropTypes from 'prop-types';
import { v4 as uuidv4 } from 'uuid';
import {
Expand All @@ -11,18 +12,19 @@ import QuestionTooltip from './QuestionTooltip';
import URLInput from '../URLInput';
import colors from '../../colors';
import './ResourceRepeater.scss';
import { OBJECTIVE_LINK_ERROR } from './constants';

export default function ResourceRepeater({
resources,
setResources,
error,
validateResources,
toolTipText,
validateOnRemove,
isLoading,
}) {
const addResource = () => {
if ((error.props.children) || resources.some((r) => !r.value)) {
return;
}
const newResources = [...resources, { key: uuidv4(), value: '' }];
setResources(newResources);
};
Expand All @@ -31,20 +33,12 @@ export default function ResourceRepeater({
const newResources = [...resources];
newResources.splice(i, 1);
setResources(newResources);

// This is an attempt to handle on remove validation for resources.
// the AR and RTR use two different approaches to validation.
// This works around it by allowing the parent component to pass in a validation function.
if (validateOnRemove) {
validateOnRemove(newResources);
} else {
validateResources();
}
validateResources();
};

const updateResource = (value, i) => {
const newResources = [...resources];
const toUpdate = { ...newResources[i], value };
const toUpdate = { ...newResources[i], value: value.trim() };
newResources.splice(i, 1, toUpdate);
setResources(newResources);
};
Expand All @@ -64,10 +58,10 @@ export default function ResourceRepeater({
Enter one resource per field. To enter more resources, select “Add new resource”
</span>
</Fieldset>
{error.props.children ? OBJECTIVE_LINK_ERROR : null}
{error.props.children ? error : null}
<div className="ttahub-resource-repeater">
{ resources.map((r, i) => (
<div key={r.key} className="display-flex" id="resources">
<div key={r.key} className={`display-flex${r.value && !isValidResourceUrl(r.value) ? ' ttahub-resource__error' : ''}`} id="resources">
<Label htmlFor={`resource-${i + 1}`} className="sr-only">
Resource
{' '}
Expand All @@ -77,7 +71,7 @@ export default function ResourceRepeater({
id={`resource-${i + 1}`}
onBlur={validateResources}
onChange={({ target: { value } }) => updateResource(value, i)}
value={r.value}
value={r.value || ''}
disabled={isLoading}
/>
{ resources.length > 1 ? (
Expand Down Expand Up @@ -113,11 +107,9 @@ ResourceRepeater.propTypes = {
validateResources: PropTypes.func.isRequired,
isLoading: PropTypes.bool,
toolTipText: PropTypes.string,
validateOnRemove: PropTypes.func,
};

ResourceRepeater.defaultProps = {
isLoading: false,
toolTipText: 'Copy & paste web address of TTA resource used for this objective. Usually an ECLKC page.',
validateOnRemove: null,
};
2 changes: 1 addition & 1 deletion frontend/src/components/GoalForm/ResourceRepeater.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
}
}

.usa-error-message + .ttahub-resource-repeater input[type="url"] {
.ttahub-resource__error input[type="url"] {
@include mixins.error;
}

Expand Down
57 changes: 43 additions & 14 deletions frontend/src/components/GoalForm/__tests__/ResourceRepeater.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import '@testing-library/jest-dom';
import React from 'react';
import {
render, screen, fireEvent,
render,
screen,
fireEvent,
act,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ResourceRepeater from '../ResourceRepeater';

describe('ResourceRepeater', () => {
Expand Down Expand Up @@ -76,29 +80,54 @@ describe('ResourceRepeater', () => {
expect(screen.queryAllByText("Copy & paste web address of TTA resource you'll use for this objective. Usually an ECLKC page.").length).toBe(2);
});

it('calls validateOnRemove() when a resource is removed', async () => {
const validateOnRemoveMock = jest.fn();
const resources = [
{ key: 1, value: 'http://www.resources.com' },
{ key: 2, value: 'http://www.resources2.com' },
];

it('cannot add a resource if the first is blank', async () => {
const setResources = jest.fn();
render(<ResourceRepeater
error={<></>}
resources={resources}
setResources={jest.fn()}
resources={[
{ key: 1, value: '' },
]}
setResources={setResources}
validateResources={jest.fn()}
status="In Progress"
isOnReport={false}
isLoading={false}
goalStatus="In Progress"
userCanEdit
validateOnRemove={validateOnRemoveMock}
/>);

const removeButton = screen.getByRole('button', { name: /remove resource 1/i });
fireEvent.click(removeButton);
const addButton = screen.getByRole('button', { name: /add new resource/i });
act(() => {
userEvent.click(addButton);
});

expect(setResources).not.toHaveBeenCalled();
const urlInputs = document.querySelectorAll('input[type="url"]');
expect(urlInputs.length).toBe(1);
});
it('cannot add a resource if there is an error', async () => {
const setResources = jest.fn();
render(<ResourceRepeater
error={<span className="usa-error">This is an error</span>}
resources={[
{ key: 1, value: 'garbelasdf' },
]}
setResources={setResources}
validateResources={jest.fn()}
status="In Progress"
isOnReport={false}
isLoading={false}
goalStatus="In Progress"
userCanEdit
/>);

const addButton = screen.getByRole('button', { name: /add new resource/i });
act(() => {
userEvent.click(addButton);
});

expect(validateOnRemoveMock).toHaveBeenCalled();
expect(setResources).not.toHaveBeenCalled();
const urlInputs = document.querySelectorAll('input[type="url"]');
expect(urlInputs.length).toBe(1);
});
});
146 changes: 83 additions & 63 deletions frontend/src/components/GoalForm/__tests__/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,84 +4,104 @@ import {
FORM_FIELD_INDEXES,
objectivesWithValidResourcesOnly,
grantsToMultiValue,
noDisallowedUrls,
} from '../constants';

describe('form constants', () => {
it('the amount of form fields and the amount of default errors should match', () => {
expect(Object.keys(FORM_FIELD_INDEXES).length).toBe(FORM_FIELD_DEFAULT_ERRORS.length);
});
});

describe('objectivesWithValidResourcesOnly', () => {
it('strips invalid resources', () => {
const objectives = [
{
resources: [
{ value: 'https://www.google.com' },
{ value: 'not a valid url' },
{ value: 'https://www.google.com' },
{ value: 'https://www.google.com ' },
{ value: ' https://www.google.com' },
],
},
describe('noDisallowedUrls', () => {
const goodUrls = [{ value: 'https://www.google.com' }];

const badUrls = [
'https://eclkc.ohs.acf.hhs.gov/professional-development/individualized-professional-development-ipd-portfolio/course-catalog',
'https://eclkc.ohs.acf.hhs.gov/professional-development/individualized-professional-development-ipd-portfolio/individualized-professional-development-ipd-portfolio',
'https://eclkc.ohs.acf.hhs.gov/cas/login',
];

expect(objectivesWithValidResourcesOnly(objectives)).toEqual([
{
resources: [
{ value: 'https://www.google.com' },
{ value: 'https://www.google.com' },
{ value: 'https://www.google.com' },
{ value: 'https://www.google.com' },
],
},
]);
it('returns true if the url is not in the disallowed list', () => {
expect(noDisallowedUrls(goodUrls)).toBe(true);
});

it('returns false if the url is in the disallowed list', () => {
badUrls.forEach((url) => {
expect(noDisallowedUrls([{ value: url }])).toBe('This link is no longer accepted in this field. Enter iPD courses used during your TTA session in the other field in this section.');
});
});
});
});

describe('validateListOfResources', () => {
it('returns false if there is an invalid resource', () => {
expect(validateListOfResources([{ value: 'http://www.test-domain.com/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain' }])).toBe(true);
expect(validateListOfResources([{ value: 'https://test.com' }])).toBe(true);
expect(validateListOfResources([{ value: 'http://test.com' }])).toBe(true);
expect(validateListOfResources([{ value: 'https://www.test.com' }])).toBe(true);
expect(validateListOfResources([{ value: 'http://www.test.com' }])).toBe(true);
expect(validateListOfResources([{ value: 'file://test.com' }])).toBe(false);
expect(validateListOfResources([{ value: 'http://test' }])).toBe(false);
expect(validateListOfResources([{ value: 'https://test' }])).toBe(false);
expect(validateListOfResources([{ value: 'http:mickeymouse.com' }])).toBe(false);
expect(validateListOfResources([{ value: 'http://google.comhttp://ask.comhttp://aol.com' }])).toBe(false);
expect(validateListOfResources([{ value: ' https://eclkc.ohs.acf.hhs.gov/sites/default/files/pdf/healthy-children-ready-learn.pdf cf.hhs.gov/policy/45-cfr-chap-xiii/1302-subpart-d-health-program-services •\tHealth Competencies https://eclkc.ohs.acf.hhs.gov/sites/default/files/pdf/health-competencies.pdf Non-ECLKC resources\t https://nrckids.org/CFOC/ https://ufhealth.org/well-child-visits#:~:text=15%20months,2%201%2F2%20years",117689' }])).toBe(false);
// eslint-disable-next-line no-useless-escape
expect(validateListOfResources([{ value: 'http:\lkj http:/test.v' }])).toBe(false);
expect(validateListOfResources([
{ value: 'https://www.google.com' },
{ value: 'not a valid url' },
{ value: 'https://www.google.com' },
])).toBe(false);
describe('validateListOfResources', () => {
it('returns false if there is an invalid resource', () => {
expect(validateListOfResources([{ value: 'http://www.test-domain.com/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain/long-domain/long/long/domain' }])).toBe(true);
expect(validateListOfResources([{ value: 'https://test.com' }])).toBe(true);
expect(validateListOfResources([{ value: 'http://test.com' }])).toBe(true);
expect(validateListOfResources([{ value: 'https://www.test.com' }])).toBe(true);
expect(validateListOfResources([{ value: 'http://www.test.com' }])).toBe(true);
expect(validateListOfResources([{ value: 'file://test.com' }])).toBe(false);
expect(validateListOfResources([{ value: 'http://test' }])).toBe(false);
expect(validateListOfResources([{ value: 'https://test' }])).toBe(false);
expect(validateListOfResources([{ value: 'http:mickeymouse.com' }])).toBe(false);
expect(validateListOfResources([{ value: 'http://google.comhttp://ask.comhttp://aol.com' }])).toBe(false);
expect(validateListOfResources([{ value: ' https://eclkc.ohs.acf.hhs.gov/sites/default/files/pdf/healthy-children-ready-learn.pdf cf.hhs.gov/policy/45-cfr-chap-xiii/1302-subpart-d-health-program-services •\tHealth Competencies https://eclkc.ohs.acf.hhs.gov/sites/default/files/pdf/health-competencies.pdf Non-ECLKC resources\t https://nrckids.org/CFOC/ https://ufhealth.org/well-child-visits#:~:text=15%20months,2%201%2F2%20years",117689' }])).toBe(false);
// eslint-disable-next-line no-useless-escape
expect(validateListOfResources([{ value: 'http:\lkj http:/test.v' }])).toBe(false);
expect(validateListOfResources([
{ value: 'https://www.google.com' },
{ value: 'not a valid url' },
{ value: 'https://www.google.com' },
])).toBe(false);
});
});
});

test('grantsToSources function should return the correct source object', () => {
const grants = [
{ numberWithProgramTypes: '123' },
{ numberWithProgramTypes: '456' },
{ numberWithProgramTypes: '789' },
];
test('grantsToSources function should return the correct source object', () => {
const grants = [
{ numberWithProgramTypes: '123' },
{ numberWithProgramTypes: '456' },
{ numberWithProgramTypes: '789' },
];

const source = {
123: 'Source 1',
456: 'Source 2',
1234: 'Source 1',
};

const source = {
123: 'Source 1',
456: 'Source 2',
1234: 'Source 1',
};
const expected = {
123: 'Source 1',
456: 'Source 2',
789: '',
};

const expected = {
123: 'Source 1',
456: 'Source 2',
789: '',
};
const result = grantsToMultiValue(grants, source);

const result = grantsToMultiValue(grants, source);
expect(result).toEqual(expected);
});
describe('objectivesWithValidResourcesOnly', () => {
it('strips invalid resources', () => {
const objectives = [
{
resources: [
{ value: 'https://www.google.com' },
{ value: 'not a valid url' },
{ value: 'https://www.google.com' },
{ value: 'https://www.google.com ' },
{ value: ' https://www.google.com' },
],
},
];

expect(result).toEqual(expected);
expect(objectivesWithValidResourcesOnly(objectives)).toEqual([
{
resources: [
{ value: 'https://www.google.com' },
{ value: 'https://www.google.com' },
{ value: 'https://www.google.com' },
{ value: 'https://www.google.com' },
],
},
]);
});
});
});
20 changes: 5 additions & 15 deletions frontend/src/components/GoalForm/constants.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,12 @@
import React from 'react';
import { v4 as uuidv4 } from 'uuid';
import { DECIMAL_BASE } from '@ttahub/common';
import { DECIMAL_BASE, DISALLOWED_URLS, isValidResourceUrl } from '@ttahub/common';
import { uniq } from 'lodash';

// regex to match a valid url, it must start with http:// or https://, have at least one dot, and not end with a dot or a space
const VALID_URL_REGEX = /^https?:\/\/.*\.[^ |^.]/;

export const isValidResourceUrl = (attempted) => {
try {
const httpOccurences = (attempted.match(/http/gi) || []).length;
if (httpOccurences !== 1 || !VALID_URL_REGEX.test(attempted)) {
return false;
}
const u = new URL(attempted);
return (u !== '');
} catch (e) {
return false;
}
export const noDisallowedUrls = (value) => {
const urls = value.map((v) => v.value);
const disallowedUrl = DISALLOWED_URLS.find((disallowed) => urls.includes(disallowed.url));
return disallowedUrl ? disallowedUrl.error : true;
};

export const objectivesWithValidResourcesOnly = (objectives) => {
Expand Down
Loading