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

UIU-3080 - Handle invalid image URLs when uploading profile photo #2670

Merged
merged 12 commits into from
Apr 29, 2024
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* Update username field validation to trim leading and trailing spaces. Refs UIU-3099.
* Fix "Total paid amount" value that set as "$NaN" on "Refund fee/fine" modal. Refs UIU-3094.
* Allow override for reminder fees with renewal blocked. Refs UICIRC-1077.
* Validate image url provided as external url for user profile picture. Refs UIU-3080.

## [10.1.0](https://github.com/folio-org/ui-users/tree/v10.1.0) (2024-03-20)
[Full Changelog](https://github.com/folio-org/ui-users/compare/v10.0.4...v10.1.0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ jest.mock('react-image', () => ({
Img: jest.fn(() => null),
}));

jest.mock('../../../../util', () => ({
isAValidImageUrl: jest.fn(() => true),
isAValidURL: jest.fn(() => true),
}));

const defaultProps = {
profilePictureMaxFileSize: 3,
profilePictureId: 'https://folio.org/wp-content/uploads/2023/08/folio-site-general-Illustration-social-image-1200.jpg',
Expand Down Expand Up @@ -157,7 +162,7 @@ describe('Edit User Profile Picture', () => {
await userEvent.click(saveButton);
expect(Img).toHaveBeenCalled();
const renderedProfileImg = Img.mock.lastCall[0];
expect(expect(renderedProfileImg.src).toContain('https://upload.wikimedia.org/wikipedia/commons/e/e2/FOLIO_400x400.jpg'));
expect(renderedProfileImg.src).toContain('https://upload.wikimedia.org/wikipedia/commons/e/e2/FOLIO_400x400.jpg');
});

it('should invoke local file upload handlers with compression', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
TextField,
} from '@folio/stripes/components';
import { FormattedMessage } from 'react-intl';
import { isAValidURL } from '../../../../util';
import { isAValidURL, isAValidImageUrl } from '../../../../util';

const ExternalLinkModal = ({
open,
Expand All @@ -23,26 +23,37 @@ const ExternalLinkModal = ({
const [inputValue, setInputValue] = useState('');
const previousInputValue = useRef(profilePictureLink);
const [disabled, setDisabled] = useState(false);
const [error, setError] = useState(false);
const externalURLValidityError = error ?
<FormattedMessage id="ui-users.information.profilePicture.externalLink.modal.externalURL.errorMessage" />
: null;
const [externalURLValidityError, setExternalURLValidityError] = useState(null);

useEffect(() => {
setInputValue(profilePictureLink);
setError(false);
}, [profilePictureLink]);

useEffect(() => {
setExternalURLValidityError(null);
if (inputValue) {
setDisabled(previousInputValue.current === inputValue);
setError(false);
} else {
setDisabled(true);
}
}, [inputValue]);

const handleSave = () => {
const handleSave = async () => {
setExternalURLValidityError(null);
if (!inputValue) return;
manvendra-s-rathore marked this conversation as resolved.
Show resolved Hide resolved

if (!isAValidURL(inputValue)) {
setExternalURLValidityError(<FormattedMessage id="ui-users.information.profilePicture.externalLink.modal.externalURL.invalidURLErrorMessage" />);
setDisabled(true);
return;
}

const isValidImgURL = await isAValidImageUrl(inputValue);
if (!isValidImgURL) {
setExternalURLValidityError(<FormattedMessage id="ui-users.information.profilePicture.externalLink.modal.externalURL.invalidImageURLErrorMessage" />);
setDisabled(true);
return;
}
onSave(inputValue);
};

Expand All @@ -51,13 +62,6 @@ const ExternalLinkModal = ({
setInputValue(e.target.value);
};

const handleBlur = () => {
if (inputValue && !isAValidURL(inputValue)) {
setError(true);
setDisabled(true);
}
};

const renderModalFooter = () => {
return (
<ModalFooter>
Expand Down Expand Up @@ -92,7 +96,6 @@ const ExternalLinkModal = ({
label={<FormattedMessage id="ui-users.information.profilePicture.externalLink.modal.externalURL" />}
error={externalURLValidityError}
onChange={handleInputChange}
onBlur={handleBlur}
value={inputValue}
hasClearIcon={false}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '@folio/jest-config-stripes/testing-library/react';
import userEvent from '@folio/jest-config-stripes/testing-library/user-event';

import { isAValidURL } from '../../../../util';
import { isAValidURL, isAValidImageUrl } from '../../../../util';

import '../../../../../../test/jest/__mock__';

Expand Down Expand Up @@ -43,6 +43,7 @@ describe('ExternalLinkModal', () => {

beforeEach(() => {
isAValidURL.mockReset();
isAValidImageUrl.mockReset();
renderExternalLinkModal(props);
});

Expand All @@ -53,6 +54,9 @@ describe('ExternalLinkModal', () => {
expect(screen.getByText('ui-users.information.profilePicture.externalLink.modal.externalURL')).toBeInTheDocument();
});
it('should call onSave', async () => {
isAValidURL.mockImplementationOnce(() => true);
isAValidImageUrl.mockImplementationOnce(() => true);

const saveButton = screen.getByRole('button', { name: 'ui-users.save' });
const inputElement = screen.getByLabelText('ui-users.information.profilePicture.externalLink.modal.externalURL');

Expand All @@ -61,14 +65,28 @@ describe('ExternalLinkModal', () => {

expect(props.onSave).toHaveBeenCalled();
});
it('should show error text when url is invalid', async () => {
it('should show error text when url is invalid url', async () => {
isAValidURL.mockImplementationOnce(() => false);

const saveButton = screen.getByRole('button', { name: 'ui-users.save' });
const inputElement = screen.getByLabelText('ui-users.information.profilePicture.externalLink.modal.externalURL');

fireEvent.change(inputElement, { target: { value: 'profile picture' } });
fireEvent.blur(inputElement);
await userEvent.click(saveButton);

await waitFor(() => expect(screen.getByText('ui-users.information.profilePicture.externalLink.modal.externalURL.invalidURLErrorMessage')).toBeInTheDocument());
});
it('should show error text when url is invalid image url', async () => {
isAValidURL.mockImplementationOnce(() => true);
isAValidImageUrl.mockImplementationOnce(() => false);

const saveButton = screen.getByRole('button', { name: 'ui-users.save' });
const inputElement = screen.getByLabelText('ui-users.information.profilePicture.externalLink.modal.externalURL');

fireEvent.change(inputElement, { target: { value: 'https://folio-org.atlassian.net/browse/UIU-3080' } });
await userEvent.click(saveButton);

await waitFor(() => expect(screen.getByText('ui-users.information.profilePicture.externalLink.modal.externalURL.errorMessage')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('ui-users.information.profilePicture.externalLink.modal.externalURL.invalidImageURLErrorMessage')).toBeInTheDocument());
});
it('should call onClose', async () => {
const cancelButton = screen.getByRole('button', { name: 'stripes-core.button.cancel' });
Expand Down
12 changes: 12 additions & 0 deletions src/components/util/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,15 @@ export const isAValidURL = (str) => {
return URL.canParse(str);
};

export const isAValidImageUrl = async (url) => {
try {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = url;
});
} catch (e) {
return false;
}
};
50 changes: 50 additions & 0 deletions src/components/util/util.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getRequestUrl,
isAffiliationsEnabled,
isDCBItem,
isAValidImageUrl,
} from './util';

const STRIPES = {
Expand Down Expand Up @@ -474,3 +475,52 @@ describe('isDCBItem ', () => {
expect(isDCBItem(item)).toBeFalsy();
});
});

describe('isAValidImageUrl', () => {
it('should return true for a valid image URL', async () => {
class MockImage {
constructor() {
this.onload = null;
this.onerror = null;
}

set src(value) {
if (this.onload) {
setTimeout(() => {
this.onload();
}, 0);
}
}
}

global.Image = MockImage;
const validImageUrl = 'https://upload.wikimedia.org/wikipedia/commons/e/e2/FOLIO_400x400.jpg';

const isValid = await isAValidImageUrl(validImageUrl);
expect(isValid).toBe(true);
});

it('should return false for an invalid image URL', async () => {
class MockImage {
constructor() {
this.onload = null;
this.onerror = null;
}

set src(value) {
if (this.onerror) {
setTimeout(() => {
this.onerror();
}, 0);
}
}
}

global.Image = MockImage;
const invalidImageUrl = 'https://example.com';

const isValid = await isAValidImageUrl(invalidImageUrl);
expect(isValid).toBe(false);
});
});

3 changes: 2 additions & 1 deletion translations/ui-users/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@
"information.profilePicture.delete": "Delete",
"information.profilePicture.externalLink.modal.updateProfilePicture": "Update profile picture",
"information.profilePicture.externalLink.modal.externalURL": "External URL",
"information.profilePicture.externalLink.modal.externalURL.errorMessage": "Invalid image URL",
"information.profilePicture.externalLink.modal.externalURL.invalidURLErrorMessage": "Invalid image URL",
"information.profilePicture.externalLink.modal.externalURL.invalidImageURLErrorMessage": "The provided URL is valid but does not point to an image file",
"information.profilePicture.delete.modal.message": "You are deleting the profile picture for {name}",
"information.profilePicture.delete.modal.heading": "Delete profile picture",
"information.profilePicture.localFile.modal.previewAndEdit": "Preview and edit",
Expand Down
Loading